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
93
94
95
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
706
707
708
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
64
65
66
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
924
925
926
927
928
929
930
931
932
933
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
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
@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
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
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) : pd.Timestamp(self.end_date)] = (
            self.value
        )
    else:
        idx = pd.date_range(start=self.start_date, end=self.end_date)
        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
547
548
549
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
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
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
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
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
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
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
768
769
770
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
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
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
198
199
200
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
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
404
405
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
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
276
277
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
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
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)
    )
    if self.phases.exists():
        return cast(  # type: ignore[explicit-any]
            pd.Series, sum(phase.trace(timerange) for phase in self.phases.all())
        )
    return pd.Series(0.0, index=timerange)

ProjectPhase ¤

Bases: FullTimeEquivalent

Phases associated with a project.

Functions¤
__str__() ¤

String representation of the ProjectPhase object.

Source code in main/models.py
943
944
945
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
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
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
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
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
972
973
974
975
976
977
978
979
980
981
982
983
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
1033
1034
1035
1036
1037
1038
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
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
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
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
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
842
843
844
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
27
28
29
def __str__(self) -> str:
    """Full name of the user."""
    return f"{self.first_name} {self.last_name}"