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
95
96
97
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
741
742
743
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
66
67
68
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
961
962
963
964
965
966
967
968
969
970
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
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
@classmethod
def from_days(  # type: ignore[explicit-any]
    cls,
    days: float,
    start_date: date,
    end_date: date,
    **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
    obj = cls(
        value=days / day_difference,
        start_date=start_date,
        end_date=end_date,
        **kwargs,
    )
    obj.clean()
    obj.save()
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
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
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 is not None:
        idx = timerange.copy()
        output = pd.Series(0.0, index=idx)
        output.loc[
            pd.Timestamp(self.start_date, tz=UTC) : pd.Timestamp(
                self.end_date, tz=UTC
            )
        ] = self.value
    else:
        idx = pd.date_range(start=self.start_date, end=self.end_date, tz=UTC)
        output = pd.Series(self.value, index=idx)

    return output

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.

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
582
583
584
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
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
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
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
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
    )
monthly_pro_rata_charge(date) ¤

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.

The last month of the project is not charged, so the charge applies from the month of the start date until the month before the end date, to ensure that no charges are made outside of the project period. For example, if a project starts on 15th January and ends on 10th April, the charge will apply for January, February and March, but not April.

Parameters:

Name Type Description Default
date date

The date for which to calculate the monthly charge, used to check if the project has started and hasn't ended yet.

required

Returns:

Type Description
float | None

The monthly charge amount, or None if the project doesn't have Pro-rata

float | None

charging or the date is outside the project period.

Source code in main/models.py
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
def monthly_pro_rata_charge(self, date: date) -> float | None:
    """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.

    The last month of the project is not charged, so the charge applies from the
    month of the start date until the month before the end date, to ensure that no
    charges are made outside of the project period. For example, if a project
    starts on 15th January and ends on 10th April, the charge will apply for
    January, February and March, but not April.

    Args:
        date: The date for which to calculate the monthly charge, used to check if
            the project has started and hasn't ended yet.

    Returns:
        The monthly charge amount, or None if the project doesn't have Pro-rata
        charging or the date is outside the project period.
    """
    if (
        self.project.charging == "Pro-rata"
        and self.project.start_date
        and self.project.end_date
        and self.project.start_date.month
        <= date.month
        < self.project.end_date.month
    ):
        months = (
            self.project.end_date.year - self.project.start_date.year
        ) * 12 + (self.project.end_date.month - self.project.start_date.month)
        return float(self.budget / months)
    return None

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
803
804
805
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
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
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
200
201
202
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
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
404
405
406
407
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
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
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."
            )
fte(timerange=None) ¤

Calculate the FTE trace for the project over a given timerange.

This is calculated by summing the trace of all the phases of the project, which are assumed to be sequential and non-overlapping.

Parameters:

Name Type Description Default
timerange DatetimeIndex | None

The timerange to calculate the FTE trace over.

None

Returns:

Type Description
Series

A pandas Series with the FTE trace over the timerange, or a trace of 0 if

Series

there are no phases.

Source code in main/models.py
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
def fte(self, timerange: pd.DatetimeIndex | None = None) -> pd.Series:  # type: ignore[explicit-any]
    """Calculate the FTE trace for the project over a given timerange.

    This is calculated by summing the trace of all the phases of the project,
    which are assumed to be sequential and non-overlapping.

    Args:
        timerange: The timerange to calculate the FTE trace over.

    Returns:
        A pandas Series with the FTE trace over the timerange, or a trace of 0 if
        there are no phases.
    """
    assert self.start_date is not None
    assert self.end_date is not None

    timerange = (
        timerange
        if timerange is not None
        else pd.date_range(start=self.start_date, end=self.end_date, tz=UTC)
    )
    if self.phases.exists():
        return cast(  # type: ignore[explicit-any]
            pd.Series, sum(phase.trace(timerange) for phase in self.phases.all())
        ) + self._excess_fte(timerange)
    return pd.Series(0.0, index=timerange)

ProjectPhase ¤

Bases: FullTimeEquivalent

Phases associated with a project.

Attributes¤
expected_days_left property ¤

Expected number of days left in the phase.

If the days were to be used homogeneously over the phase length, this function calculates how many days of effort are left from today. In phases that have not started (today < start date), the days left will be the total number of days, and phases that are gone (today > end date) the total number of days will be zero.

Functions¤
__str__() ¤

String representation of the ProjectPhase object.

Source code in main/models.py
980
981
982
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
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
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
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
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
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
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
1070
1071
1072
1073
1074
1075
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
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
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
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
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
877
878
879
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.

Functions¤
__str__() ¤

Full name of the user.

Source code in main/models.py
29
30
31
def __str__(self) -> str:
    """Full name of the user."""
    return f"{self.first_name} {self.last_name}"