from typing import Sequence from .dates_add_days import add_days, days_between, parse_iso_date from .dates_add_months import add_months 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), )