Functional Weave
Code in Rust

education.cpd-hours@1.0.0

impl/python.py

2,585 bytes · the Python implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

from typing import Sequence

from .dates_add_days import add_days, days_between, parse_iso_date  ← from dates.add-days ^1.0.0 · built alongside by fune
from .dates_add_months import add_months  ← from dates.add-months ^1.0.0 · built alongside by fune
from .education_cpd_hours_types import CpdEntry, CpdProgress, CpdRequirement


def _is_int(value: object) -> bool:
    return isinstance(value, int) and not isinstance(value, bool)


def cpd_hours(entries: Sequence[CpdEntry], requirement: CpdRequirement, on_date: str) -> CpdProgress:
    """CPD minutes logged in the period that applies on on_date, against the
    requirement. Fixed period boundaries are anchor + k x period_months, each
    worked out from the anchor so month-end clamping never accumulates."""
    on = parse_iso_date(on_date)
    required = requirement.required_minutes
    months = requirement.period_months
    anchor = requirement.anchor
    if not _is_int(required) or required < 0:
        raise ValueError("requiredMinutes must be a whole number of 0 or more, received %s" % (required,))
    if not _is_int(months) or months < 1:
        raise ValueError("periodMonths must be a whole number of 1 or more, received %s" % (months,))
    if requirement.rolling:
        if anchor is not None:
            raise ValueError("a rolling period takes no anchor")
        period_end = on_date
        period_start = add_days(add_months(on_date, -months), 1)
    else:
        if anchor is None:
            raise ValueError("a fixed period needs an anchor date")
        a = parse_iso_date(anchor)
        months_apart = (on.year - a.year) * 12 + (on.month - a.month)
        k = months_apart // months
        while add_months(anchor, k * months) > on_date:
            k -= 1
        while add_months(anchor, (k + 1) * months) <= on_date:
            k += 1
        period_start = add_months(anchor, k * months)
        period_end = add_days(add_months(anchor, (k + 1) * months), -1)

    logged = 0
    counted = 0
    for e in entries:
        parse_iso_date(e.date)
        if not _is_int(e.minutes) or e.minutes < 0:
            raise ValueError("minutes must be a whole number of 0 or more, received %s on %s" % (e.minutes, e.date))
        if period_start <= e.date <= period_end and e.date <= on_date:
            logged += e.minutes
            counted += 1
    shortfall = max(0, required - logged)
    return CpdProgress(
        period_start=period_start,
        period_end=period_end,
        logged_minutes=logged,
        entries_counted=counted,
        required_minutes=required,
        shortfall_minutes=shortfall,
        met=shortfall == 0,
        days_remaining=days_between(on_date, period_end),
    )