Skip to content

utils

main.utils ¤

General utilities for ProCAT.

Classes¤

Functions:¤

create_HoRSE_group(apps, *args) ¤

Create HoRSE group.

Source code in main/utils.py
62
63
64
65
def create_HoRSE_group(apps: Any, *args: Any) -> None:  # type: ignore [explicit-any]
    """Create HoRSE group."""
    Group = apps.get_model("auth", "Group")
    Group.objects.get_or_create(name="HoRSE")[0]

create_RSETeam_group(apps, *args) ¤

Create RSETeam group and add permissions.

Source code in main/utils.py
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
def create_RSETeam_group(apps: Any, *args: Any) -> None:  # type: ignore [explicit-any]
    """Create RSETeam group and add permissions."""
    Group = apps.get_model("auth", "Group")
    rse_team = Group.objects.get_or_create(name="RSETeam")[0]

    # Permissions have to be created before applying them
    for app_config in apps.get_app_configs():
        app_config.models_module = True
        create_permissions(app_config, verbosity=0)
        app_config.models_module = None

    # Now we get the relevant permission and add it to the group
    view_project = apps.get_model("auth", "Permission").objects.get(
        codename="view_project"
    )
    rse_team.permissions.add(view_project)

create_analysis(*args) ¤

Create default analysis codes.

Source code in main/utils.py
49
50
51
52
53
def create_analysis(*args: Any) -> None:  # type: ignore [explicit-any]
    """Create default analysis codes."""
    models.AnalysisCode.objects.bulk_create(
        [models.AnalysisCode(**ac) for ac in ANALYSIS_CODES]
    )

days_to_fte(start_date, end_date, days) ¤

Convert a number of days of effort into an FTE value over a date range.

The period is measured in seconds internally, so fractional days (e.g. if start_date/end_date are datetimes with a time component) are handled correctly.

Parameters:

Name Type Description Default
start_date date

The start of the period over which the effort is spread.

required
end_date date

The end of the period over which the effort is spread.

required
days float

The number of (working) days of effort within that period.

required

Returns:

Type Description
float

The FTE (full-time-equivalent) value equivalent to days of effort spread

float

over the period from start_date to end_date.

Source code in main/utils.py
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
def days_to_fte(start_date: date, end_date: date, days: float) -> float:
    """Convert a number of days of effort into an FTE value over a date range.

    The period is measured in seconds internally, so fractional days (e.g. if
    `start_date`/`end_date` are datetimes with a time component) are handled
    correctly.

    Args:
        start_date: The start of the period over which the effort is spread.
        end_date: The end of the period over which the effort is spread.
        days: The number of (working) days of effort within that period.

    Returns:
        The FTE (full-time-equivalent) value equivalent to `days` of effort spread
        over the period from `start_date` to `end_date`.
    """
    date_difference = (end_date - start_date).total_seconds() / SECONDS_PER_DAY
    working_days_in_period = date_difference * WORKING_DAYS / 365
    return float(days / working_days_in_period)

destroy_HoRSE_group(apps, *args) ¤

Delete HoRSE group.

Source code in main/utils.py
68
69
70
71
def destroy_HoRSE_group(apps: Any, *args: Any) -> None:  # type: ignore [explicit-any]
    """Delete HoRSE group."""
    Group = apps.get_model("auth", "Group")
    Group.objects.filter(name="HoRSE").delete()

destroy_RSETeam_group(apps, *args) ¤

Delete RSETeam group.

Note that this deletes the group but does not delete the permissions associated with the group, so it is not truly reversible.

Source code in main/utils.py
92
93
94
95
96
97
98
99
def destroy_RSETeam_group(apps: Any, *args: Any) -> None:  # type: ignore [explicit-any]
    """Delete RSETeam group.

    Note that this deletes the group but does not delete the permissions
    associated with the group, so it is not truly reversible.
    """
    Group = apps.get_model("auth", "Group")
    Group.objects.filter(name="RSETeam").delete()

destroy_analysis(*args) ¤

Delete default analysis codes.

Source code in main/utils.py
56
57
58
59
def destroy_analysis(*args: Any) -> None:  # type: ignore [explicit-any]
    """Delete default analysis codes."""
    codes = cast("Iterable[str]", [ac["code"] for ac in ANALYSIS_CODES])
    models.AnalysisCode.objects.filter(code__in=codes).delete()

format_currency(value) ¤

Format a float value as a GBP currency with two decimal places.

Source code in main/utils.py
281
282
283
def format_currency(value: float) -> str:
    """Format a float value as a GBP currency with two decimal places."""
    return f"£{value:.2f}"

fte_to_days(start_date, end_date, fte) ¤

Convert an FTE value over a date range into a number of days of effort.

The period is measured in seconds internally, so fractional days (e.g. if start_date/end_date are datetimes with a time component) are handled correctly.

Parameters:

Name Type Description Default
start_date date

The start of the period over which the FTE is spread.

required
end_date date

The end of the period over which the FTE is spread.

required
fte float

The FTE (full-time-equivalent) value.

required

Returns:

Type Description
float

The number of (working) days of effort equivalent to fte spread over

float

the period from start_date to end_date.

Source code in main/utils.py
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
def fte_to_days(start_date: date, end_date: date, fte: float) -> float:
    """Convert an FTE value over a date range into a number of days of effort.

    The period is measured in seconds internally, so fractional days (e.g. if
    `start_date`/`end_date` are datetimes with a time component) are handled
    correctly.

    Args:
        start_date: The start of the period over which the FTE is spread.
        end_date: The end of the period over which the FTE is spread.
        fte: The FTE (full-time-equivalent) value.

    Returns:
        The number of (working) days of effort equivalent to `fte` spread over
        the period from `start_date` to `end_date`.
    """
    date_difference = (end_date - start_date).total_seconds() / SECONDS_PER_DAY
    working_days_in_period = date_difference * WORKING_DAYS / 365
    return float(fte * working_days_in_period)

get_budget_status(date=None) ¤

Get the budget status of a funding.

Source code in main/utils.py
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
def get_budget_status(
    date: date | None = None,
) -> tuple[list[Funding], list[Funding]]:
    """Get the budget status of a funding."""
    if date is None:
        date = timezone.now().date()

    funds_ran_out_not_expired = list(
        Funding.objects.filter(
            expiry_date__gt=date, project__status__in=["Active", "Maintenance"]
        )
    )
    funds_ran_out_not_expired = [
        fund for fund in funds_ran_out_not_expired if fund.funding_left <= 0
    ]

    funding_expired_budget_left = list(
        Funding.objects.filter(
            expiry_date__lt=date, project__status__in=["Active", "Maintenance"]
        )
    )
    funding_expired_budget_left = [
        fund for fund in funding_expired_budget_left if fund.funding_left > 0
    ]
    return funds_ran_out_not_expired, funding_expired_budget_left

get_calendar_year_dates() ¤

Get the start and end dates for the current calendar year.

Source code in main/utils.py
261
262
263
264
265
266
def get_calendar_year_dates() -> tuple[datetime, datetime]:
    """Get the start and end dates for the current calendar year."""
    today = timezone.now()
    start = today.replace(day=1, month=1)
    end = today.replace(day=31, month=12)
    return start, end

get_current_and_last_month(date=None) ¤

Get the start of the last month and current month, and their names.

Source code in main/utils.py
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
def get_current_and_last_month(
    date: datetime | None = None,
) -> tuple[datetime, str, datetime, str]:
    """Get the start of the last month and current month, and their names."""
    if date is None:
        date = timezone.now()

    current_month_start = datetime(year=date.year, month=date.month, day=1)
    last_month_start = (current_month_start - timedelta(days=1)).replace(day=1)

    last_month_name = last_month_start.strftime("%B")
    current_month_name = current_month_start.strftime("%B")

    return (
        last_month_start,
        last_month_name,
        current_month_start,
        current_month_name,
    )

get_financial_year_dates() ¤

Get the start and end dates for the current financial year.

Source code in main/utils.py
269
270
271
272
273
274
275
276
277
278
def get_financial_year_dates() -> tuple[datetime, datetime]:
    """Get the start and end dates for the current financial year."""
    today = timezone.now()
    if today.month > 8:
        start = today.replace(day=1, month=8)
        end = today.replace(day=31, month=7, year=today.year + 1)
    else:
        start = today.replace(day=1, month=8, year=today.year - 1)
        end = today.replace(day=31, month=7, year=today.year)
    return start, end

get_head_email() ¤

Get the emails of the HoRSE group users.

Source code in main/utils.py
149
150
151
152
153
154
155
def get_head_email() -> list[str]:
    """Get the emails of the HoRSE group users."""
    User = get_user_model()
    head_email = User.objects.filter(groups__name="HoRSE").values_list(
        "email", flat=True
    )
    return list(head_email)

get_logged_hours(entries) ¤

Calculate total logged hours from time entries.

Source code in main/utils.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
def get_logged_hours(
    entries: Iterable["TimeEntry"],
) -> tuple[float, str]:
    """Calculate total logged hours from time entries."""
    project_hours: defaultdict[str, float] = defaultdict(
        float
    )  # <- This defaults to 0.0
    total_hours = 0.0

    for entry in entries:
        project_name = entry.project.name
        hours = (entry.end_time - entry.start_time).total_seconds() / 3600
        total_hours += hours
        project_hours[project_name] += hours

    project_work_summary = "\n".join(
        [
            f"{project}: {round(hours / 7, 1)} days"
            # Assuming 7 hours/workday
            for project, hours in project_hours.items()
        ]
    )

    return total_hours, project_work_summary

get_month_dates_for_previous_years() ¤

Get the start and end date of each month for the previous 3 years.

Source code in main/utils.py
185
186
187
188
189
190
191
192
193
194
195
196
197
198
def get_month_dates_for_previous_years() -> list[tuple[date, date]]:
    """Get the start and end date of each month for the previous 3 years."""
    dates = []
    today = timezone.now().date()

    start_current_month = today.replace(day=1)
    for _ in range(36):
        end_prev_month = start_current_month - timedelta(days=1)
        start_prev_month = end_prev_month.replace(day=1)
        dates.append((start_prev_month, end_prev_month))
        start_current_month = start_prev_month

    dates.reverse()
    return dates

get_projects_with_days_used_exceeding_days_left() ¤

Get projects whose time entries exceed the total effort of the project.

Source code in main/utils.py
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
def get_projects_with_days_used_exceeding_days_left() -> list[
    tuple[Project, float, float | None]
]:
    """Get projects whose time entries exceed the total effort of the project."""
    projects = Project.objects.filter(status__in=("Active", "Maintenance"))
    projects_with_negative_days_left = []

    for project in projects:
        if project.days_left is None:
            continue

        days_left, _ = project.days_left
        if days_left < 0:
            projects_with_negative_days_left.append(
                (project, days_left, project.total_effort)
            )

    return projects_with_negative_days_left

order_queryset_by_property(queryset, property, is_descending) ¤

Orders a queryset according to a specified Model property.

Creates a Django conditional expression to assign the position of the model in a queryset according to its model ID (using a custom ordering). The conditional expression is then provided to the QuerySet.order_by() function. This can be used to update the ordering of a queryset column in a Table.

Parameters:

Name Type Description Default
queryset QuerySet[Any]

a model queryset for ordering

required
property str

the name of the model property with which to order the queryset

required
is_descending bool

bool to indicate whether the property should be sorted by descending (or ascending) order

required

Returns:

Type Description
QuerySet[Any]

The queryset ordered according to the property.

Source code in main/utils.py
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
def order_queryset_by_property(  # type: ignore[explicit-any]
    queryset: QuerySet[Any], property: str, is_descending: bool
) -> QuerySet[Any]:
    """Orders a queryset according to a specified Model property.

    Creates a Django conditional expression to assign the position
    of the model in a queryset according to its model ID (using a
    custom ordering). The conditional expression is then provided to
    the QuerySet.order_by() function. This can be used to update the
    ordering of a queryset column in a Table.

    Args:
        queryset: a model queryset for ordering
        property: the name of the model property with which to order
            the queryset
        is_descending: bool to indicate whether the property should
            be sorted by descending (or ascending) order

    Returns:
        The queryset ordered according to the property.
    """
    queryset = queryset.order_by("id")
    model_ids = list(queryset.values_list("id", flat=True))
    values = [getattr(obj, property) for obj in queryset]
    sorted_indexes = sorted(
        range(len(values)),
        key=lambda i: (values[i] is not None, values[i]),
        reverse=is_descending,
    )
    # Create conditional expression using custom ordering
    preserved_ordering = Case(
        *[
            When(id=model_ids[id], then=position)
            for position, id in enumerate(sorted_indexes)
        ]
    )
    queryset = queryset.order_by(preserved_ordering)
    return queryset

style_fraction_badge(value, size_class='fs-5') ¤

Render a number/percentage pair as a colour-graded Bootstrap badge.

Used for metrics where the percentage indicates how much of a resource is left (e.g. days or weeks remaining): green when comfortably placed, amber when getting low, and red when critical. If there is no value to show (e.g. the metric isn't relevant for the project's current status), a neutral grey 'N/A' badge is returned instead.

Parameters:

Name Type Description Default
value tuple[float, float] | None

A tuple of (absolute number, percentage), or None if not applicable.

required
size_class str

The Bootstrap font-size utility class to use for the badge text (e.g. 'fs-5', the default used in the Project list table, or 'fs-4' for the bigger badges on the Project detail page).

'fs-5'

Returns:

Type Description
SafeString

Safe HTML string with the appropriate Bootstrap badge styling.

Source code in main/utils.py
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
def style_fraction_badge(
    value: tuple[float, float] | None, size_class: str = "fs-5"
) -> SafeString:
    """Render a number/percentage pair as a colour-graded Bootstrap badge.

    Used for metrics where the percentage indicates how much of a resource is
    left (e.g. days or weeks remaining): green when comfortably placed, amber
    when getting low, and red when critical. If there is no value to show
    (e.g. the metric isn't relevant for the project's current status), a
    neutral grey 'N/A' badge is returned instead.

    Args:
        value: A tuple of (absolute number, percentage), or None if not
            applicable.
        size_class: The Bootstrap font-size utility class to use for the
            badge text (e.g. 'fs-5', the default used in the Project list
            table, or 'fs-4' for the bigger badges on the Project detail
            page).

    Returns:
        Safe HTML string with the appropriate Bootstrap badge styling.
    """
    base_class = _BADGE_BASE_CLASS.format(size_class=size_class)

    if value is None:
        return mark_safe(f'<span class="{base_class} bg-secondary">N/A</span>')

    num, frac = value
    if frac <= 10:
        colour = "bg-danger"
    elif frac <= 30:
        colour = "bg-warning"
    else:
        colour = "bg-success"

    return mark_safe(
        f'<span class="{base_class} {colour}">{num:.1f} ({frac:.1f}%)</span>'
    )

style_plain_badge(value, formatter, size_class='fs-5') ¤

Render a single value as a neutral 'big badge' span.

Used for metrics that don't have a good/bad fraction associated with them (e.g. total funding, total effort), so they are always styled the same, neutral colour instead of the red/amber/green grading used by style_fraction_badge.

Parameters:

Name Type Description Default
value float | None

The value to display inside the badge, or None if not applicable.

required
formatter Callable[[float], str]

A function that turns value into the text to display inside the badge (e.g. format_currency).

required
size_class str

The Bootstrap font-size utility class to use for the badge text (e.g. 'fs-5', the default, or 'fs-4' for the bigger badges on the Project detail page).

'fs-5'

Returns:

Type Description
SafeString

Safe HTML string with the appropriate Bootstrap badge styling.

Source code in main/utils.py
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
def style_plain_badge(
    value: float | None, formatter: Callable[[float], str], size_class: str = "fs-5"
) -> SafeString:
    """Render a single value as a neutral 'big badge' span.

    Used for metrics that don't have a good/bad fraction associated with them
    (e.g. total funding, total effort), so they are always styled the same,
    neutral colour instead of the red/amber/green grading used by
    `style_fraction_badge`.

    Args:
        value: The value to display inside the badge, or None if not
            applicable.
        formatter: A function that turns `value` into the text to display
            inside the badge (e.g. `format_currency`).
        size_class: The Bootstrap font-size utility class to use for the
            badge text (e.g. 'fs-5', the default, or 'fs-4' for the bigger
            badges on the Project detail page).

    Returns:
        Safe HTML string with the appropriate Bootstrap badge styling.
    """
    base_class = _BADGE_BASE_CLASS.format(size_class=size_class)

    if value is None:
        return mark_safe(f'<span class="{base_class} bg-secondary">N/A</span>')

    return mark_safe(f'<span class="{base_class} bg-primary">{formatter(value)}</span>')