Skip to content

models

main.models ¤

Models module for main app.

Classes¤

AnalysisCode ¤

Bases: Model

Analysis code to use during charging.

Functions¤
__str__() ¤

String representation of the Analysis Code object.

Source code in main/models.py
91
92
93
def __str__(self) -> str:
    """String representation of the Analysis Code object."""
    return f"{self.code} - {self.description}"

Capacity ¤

Bases: Model

Proportion of working time that team members are able to work on projects.

Classes¤
Meta ¤

Meta class for the model.

Functions¤
__str__() ¤

String representation of the Capacity object.

Source code in main/models.py
663
664
665
def __str__(self) -> str:
    """String representation of the Capacity object."""
    return f"From {self.start_date}, the capacity of {self.user} is {self.value}."

Department ¤

Bases: Model

Model to manage the departments.

You can find the faculties and potential departments in:

https://www.imperial.ac.uk/faculties-and-departments/

Functions¤
__str__() ¤

String representation of the Department object.

Source code in main/models.py
62
63
64
def __str__(self) -> str:
    """String representation of the Department object."""
    return f"{self.name} - {self.faculty}"

FullTimeEquivalent ¤

Bases: Model

Full-time-equivalent model for user and projects.

Attributes¤
days property ¤

Convert FTE to days using the working days in a year in the settings.

Classes¤
Meta ¤

Model metadata.

Functions¤
clean() ¤

Ensure start date comes before end date and that value 0 or positive.

Source code in main/models.py
874
875
876
877
878
879
880
881
882
883
def clean(self) -> None:
    """Ensure start date comes before end date and that value 0 or positive."""
    super().clean()
    if self.end_date <= self.start_date:
        raise ValidationError("The end date must be after the start date.")

    if self.value < 0:
        raise ValidationError(
            "The FTE value must be greater than or equal to zero."
        )
from_days(days, start_date, end_date, **kwargs) classmethod ¤

Creates an FTE object given a number of days time period.

Source code in main/models.py
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
@classmethod
def from_days(  # type: ignore[explicit-any]
    cls,
    days: int,
    start_date: datetime,
    end_date: datetime,
    **kwargs: Any,
) -> None:
    """Creates an FTE object given a number of days time period."""
    # get date difference in fractional days
    date_difference = (end_date - start_date).days
    # use WORKING_DAYS to estimate day_difference minus weekends & holidays
    day_difference = date_difference * WORKING_DAYS / 365
    # FTE will then be the # of days work / the (weighted) time period in days
    cls.objects.create(  # type: ignore[attr-defined]
        value=days / day_difference,
        start_date=start_date,
        end_date=end_date,
        **kwargs,
    )
trace(timerange=None) ¤

Convert the FTE to a dataframe.

If timerange is provided, those dates are used, otherwise a datetime index is created using the start and end dates of the FTE object.

Source code in main/models.py
861
862
863
864
865
866
867
868
869
870
871
872
def trace(self, timerange: pd.DatetimeIndex | None = None) -> "pd.Series[float]":
    """Convert the FTE to a dataframe.

    If timerange is provided, those dates are used, otherwise a datetime index is
    created using the start and end dates of the FTE object.
    """
    if timerange:
        idx = timerange.copy()
    else:
        idx = pd.date_range(start=self.start_date, end=self.end_date)

    return pd.Series(self.value, index=idx)

Funding ¤

Bases: Model

Funding associated with a project.

Attributes¤
effort property ¤

Provide the effort in days, calculated based on the budget and daily rate.

Returns:

Type Description
float

The total number of days of effort provided by the funding.

effort_left property ¤

Provide the effort left in days.

Returns:

Type Description
float

The number of days worth of effort left.

funding_left property ¤

Provide the funding left in currency.

Funding left is calculated based on 'Confirmed' monthly charges.

Returns:

Type Description
Decimal

The amount of funding left.

monthly_pro_rata_charge property ¤

Calculate the charge per month if the project has Pro-rata charging.

Calculates the number of months between project start and end date regardless of the day of the month so the monthly charge will be the same regardless of the number of days in the month.

project_code property ¤

Provide the project code, containing the cost centre and activity code.

Returns:

Type Description
str

The designated project code.

Classes¤
Meta ¤

Meta class for the model.

Functions¤
__str__() ¤

String representation of the Funding object.

Source code in main/models.py
518
519
520
def __str__(self) -> str:
    """String representation of the Funding object."""
    return f"{self.project} - £{self.budget:.2f} - {self.project_code}"
clean() ¤

Ensure that the activity code has a valid value.

Source code in main/models.py
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
def clean(self) -> None:
    """Ensure that the activity code has a valid value."""
    if (
        self.project
        and self.project.status in ("Active", "Confirmed")
        and not self.is_complete()
    ):
        raise ValidationError(
            "Funding of Active and Confirmed projects must be complete."
        )

    allowed_characters = ["P", "F", "G", "I"]
    if self.activity and (
        len(self.activity) != 6
        or not self.activity.isalnum()
        or self.activity[0] not in allowed_characters
    ):
        raise ValidationError(
            "Activity code must be 6 alphanumeric characters starting with P, F, "
            "G or I."
        )
is_complete() ¤

Checks if funding record is complete.

This is only relevant to funding where source is external.

Source code in main/models.py
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
def is_complete(self) -> bool:
    """Checks if funding record is complete.

    This is only relevant to funding where source is external.
    """
    if self.source == "Internal":
        return True

    return bool(
        self.funding_body
        and self.cost_centre
        and self.activity
        and self.analysis_code
        and self.expiry_date
    )

MonthlyCharge ¤

Bases: Model

Monthly charge for a specific project, account and analysis code.

Functions¤
__str__() ¤

String representation of the MonthlyCharge object.

Source code in main/models.py
725
726
727
def __str__(self) -> str:
    """String representation of the MonthlyCharge object."""
    return self.description
clean() ¤

Ensure the charge has valid funding attached and description if Manual.

Source code in main/models.py
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
def clean(self) -> None:
    """Ensure the charge has valid funding attached and description if Manual."""
    super().clean()
    if not self.funding.expiry_date:
        raise ValidationError("Funding source must have an expiry date.")

    if (
        self.date > self.funding.expiry_date
        or self.funding.funding_left < 0  # After deducting charge amount
    ):
        raise ValidationError(
            "Monthly charge must not exceed the funding date or amount."
        )

    if self.project.charging == "Manual":
        if not self.description:
            raise ValidationError(
                "Line description needed for manual charging method."
            )
    else:
        self.description = (
            f"RSE Project {self.project} ({self.funding.project_code}): "
            f"{self.date.month}/{self.date.year} [rcs-manager@imperial.ac.uk]"
        )

Project ¤

Bases: Warning, Model

Software project details.

Attributes¤
days_left property ¤

Provide the days worth of effort left.

Returns:

Type Description
tuple[float, float] | None

The number of days and percentage worth of effort left, or None if there is

tuple[float, float] | None

no funding information.

effort_per_day property ¤

Calculate the estimated effort per day.

Considers only working (business) days.

Returns:

Type Description
float | None

Float representing the estimated effort per day over project lifespan.

percent_effort_left property ¤

Provide the percentage of effort left.

Returns:

Type Description
float | None

The percentage of effort left, or None if there is no funding information.

total_effort property ¤

Provide the total days worth of effort available from funding.

Returns:

Type Description
float | None

The total number of days effort, or None if there is no funding information.

total_funding_left property ¤

Provide the total funding left after deducting confirmed charges.

Returns:

Type Description
Decimal | None

The total monetary amount of funding left, or none if there is no funding

Decimal | None

information.

total_working_days property ¤

Provide the total number of working (business) days given the dates.

Returns:

Type Description
int | None

Number of working days between the project start and end date.

weeks_to_deadline property ¤

Provide the number of weeks left until project deadline.

Only relevant for active projects.

Returns:

Type Description
tuple[int, float] | None

The number of weeks left or None if the project is Tentative or Not done.

Functions¤
__str__() ¤

String representation of the Project object.

Source code in main/models.py
196
197
198
def __str__(self) -> str:
    """String representation of the Project object."""
    return self.name
check_and_notify_status() ¤

Check the project status and notify accordingly.

Source code in main/models.py
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
def check_and_notify_status(self) -> None:
    """Check the project status and notify accordingly."""
    from .tasks import notify_left_threshold

    check = False

    assert self.lead and hasattr(self.lead, "email")

    for threshold in sorted(EFFORT_LEFT_THRESHOLD):
        if self.percent_effort_left is None or self.percent_effort_left > threshold:
            continue

        if str(threshold) in self.notifications_effort:
            # Already notified for this threshold in the past
            break

        notify_left_threshold(
            email=self.lead.email,
            lead=self.lead.get_full_name(),
            project_name=self.name,
            threshold_type="effort",
            threshold=threshold,
            value=self.days_left[0] if self.days_left else 0,
        )
        self.notifications_effort[str(threshold)] = (
            timezone.now().date().isoformat()
        )
        check = True
        break

    for threshold in sorted(WEEKS_LEFT_THRESHOLD):
        if self.weeks_to_deadline is None or self.weeks_to_deadline[1] > threshold:
            continue

        if str(threshold) in self.notifications_weeks:
            # Already notified for this threshold in the past
            break

        notify_left_threshold(
            email=self.lead.email,
            lead=self.lead.get_full_name(),
            project_name=self.name,
            threshold_type="weeks",
            threshold=threshold,
            value=self.weeks_to_deadline[0] if self.weeks_to_deadline else 0,
        )
        self.notifications_weeks[str(threshold)] = timezone.now().date().isoformat()
        check = True
        break

    if check:
        self.save(update_fields=["notifications_effort", "notifications_weeks"])
clean() ¤

Ensure all fields have a value unless status is 'Tentative' or 'Not done'.

It also checks that, if present, the end date is after the start date.

Source code in main/models.py
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
def clean(self) -> None:
    """Ensure all fields have a value unless status is 'Tentative' or 'Not done'.

    It also checks that, if present, the end date is after the start date.
    """
    if self.status == "Tentative" or self.status == "Not done":
        return super().clean()

    if not self.start_date or not self.end_date or not self.lead:
        raise ValidationError(
            "All fields are mandatory except if Project status is 'Tentative' or "
            "'Not done'."
        )

    if self.end_date <= self.start_date:
        raise ValidationError("The end date must be after the start date.")

    if self.pk is not None and self.status in ("Active", "Confirmed"):
        if not self.funding_source.exists():
            raise ValidationError(
                "Active and Confirmed projects must have at least 1 funding source."
            )

        if not all([f.is_complete() for f in self.funding_source.all()]):
            raise ValidationError(
                "Funding of Active and Confirmed projects must be complete."
            )

ProjectPhase ¤

Bases: FullTimeEquivalent

Phases associated with a project.

Functions¤
__str__() ¤

String representation of the ProjectPhase object.

Source code in main/models.py
893
894
895
def __str__(self) -> str:
    """String representation of the ProjectPhase object."""
    return f"{self.project.name} - {self.start_date} -> {self.end_date}"
check_overlapping_phases() ¤

Check the phase doesn't overlap with another phase (by 1 day).

Source code in main/models.py
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
def check_overlapping_phases(self) -> None:
    """Check the phase doesn't overlap with another phase (by 1 day)."""
    # check start within Phases_starts ≤ Phase_new_start ≤ Phases_ends
    overlapping_start = ProjectPhase.objects.filter(
        project=self.project,
        start_date__lte=self.start_date,
        end_date__gte=self.start_date,
    )
    # check end within phase Phases_starts ≤ Phase_new_end ≤ Phases_ends
    overlapping_end = ProjectPhase.objects.filter(
        project=self.project,
        start_date__lte=self.end_date,
        end_date__gte=self.end_date,
    )
    # combine querysets
    overlapping = (overlapping_start | overlapping_end).distinct()

    # Exclude self if this is an update (not a new instance)
    if self.pk:
        overlapping = overlapping.exclude(pk=self.pk)

    if overlapping.exists():
        first_conflict = overlapping.first()
        raise ValidationError(
            "Phase period must not overlap with other phase periods for the same "
            f"project: {first_conflict.start_date} -> "  # type: ignore [union-attr]
            f"{first_conflict.end_date} vs. {self.start_date} -> {self.end_date}"  # type: ignore [union-attr]
        )
check_phase_alignment() ¤

Ensures phases are aligned but separated by 1 day.

Source code in main/models.py
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
def check_phase_alignment(self) -> None:
    """Ensures phases are aligned but separated by 1 day."""
    touching = ProjectPhase.objects.filter(
        Q(project=self.project)
        & (
            Q(start_date=self.end_date + timedelta(days=1))
            | Q(end_date=self.start_date - timedelta(days=1))
        )
    )

    if not (
        touching.exists()
        or self.start_date == self.project.start_date
        or self.end_date == self.project.end_date
    ):
        raise ValidationError(
            "Phase period must align with the start or end of a project or phase."
        )
check_phase_in_project() ¤

Ensure the start phase dates are within the project dates.

Source code in main/models.py
922
923
924
925
926
927
928
929
930
931
932
933
def check_phase_in_project(self) -> None:
    """Ensure the start phase dates are within the project dates."""
    assert self.project.start_date is not None
    assert self.project.end_date is not None
    if (
        self.project.start_date > self.start_date
        or self.project.end_date < self.end_date
    ):
        raise ValidationError(
            "Phase period must be within the project period: "
            f"{self.project.start_date} -> {self.project.end_date}"
        )
check_project_funding() ¤

Check the project has funding before the phase can be added.

Source code in main/models.py
983
984
985
986
987
988
def check_project_funding(self) -> None:
    """Check the project has funding before the phase can be added."""
    if not self.project.funding_source.exists():
        raise ValidationError(
            "Project must have associated funding before phases can be added."
        )
clean() ¤

Ensures that phase dates are sensible.

Ensures start is before the end date (from FTE clean). Ensures phase within project period. Ensures the phase isn't covered by any other phases. Ensures at least phase start or end date aligns with other phases or project dates. Ensures project has funding before phase added.

Source code in main/models.py
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
def clean(self) -> None:
    """Ensures that phase dates are sensible.

    Ensures start is before the end date (from FTE clean).
    Ensures phase within project period.
    Ensures the phase isn't covered by any other phases.
    Ensures at least phase start or end date aligns with other phases or project
        dates.
    Ensures project has funding before phase added.
    """
    super().clean()

    self.check_phase_in_project()
    self.check_overlapping_phases()
    self.check_phase_alignment()
    self.check_project_funding()
save(**kwargs) ¤

Saves the object to the database.

This overwrites models.Model.save() to keep the days constant if the start or end date changes, modifying the FTE value. Except if value has also changed in the same modification.

Source code in main/models.py
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
def save(self, **kwargs: Any) -> None:  # type: ignore
    """Saves the object to the database.

    This overwrites models.Model.save() to keep the days constant if the start or
    end date changes, modifying the FTE value. Except if `value` has also changed in
    the same modification.
    """
    update_fields = kwargs.get("update_fields", {})

    # If value has changed, then we don't do anything extra
    if "value" in update_fields:
        pass

    # If dates change, we update the value so the days remain constant
    elif "start_date" in update_fields or "end_date" in update_fields:
        # get old date (from DB)
        old_days = ProjectPhase.objects.get(pk=self.pk).days
        # update the value keeping the days constant by updating FTE value
        self.value = old_days / (
            (self.end_date - self.start_date).days * WORKING_DAYS / 365
        )
        kwargs["update_fields"] = {"value"}.union(update_fields)

    super().save(**kwargs)

TimeEntry ¤

Bases: Model

Time entry for a user.

Functions¤
__str__() ¤

String representation of the Time Entry object.

Source code in main/models.py
799
800
801
def __str__(self) -> str:
    """String representation of the Time Entry object."""
    return f"{self.user} - {self.project} - {self.start_time} to {self.end_time}"

User ¤

Bases: AbstractUser

Custom user model.