Aug 4 - Update code to follow up - MT11

This commit is contained in:
2026-08-04 13:00:21 -04:00
parent ceb0b806af
commit 3c2489e289
10 changed files with 1468 additions and 49 deletions
+327 -12
View File
@@ -27,12 +27,93 @@ phase43 adds the single-tenant "plan" semantics alongside that:
deactivates a one-time schedule or rolls a recurring one forward.
`next_run_at` is the due datetime for both modes.
phase45/46/47 port the single-tenant recurrence + end-date model
(ST phase43 + phase44) onto this table without renaming anything:
phase45 frequency ENUM gains 'once', 'bi-annually' and 'annually'
phase46 weekdays / month_mode / day_of_month / nth_week / nth_weekday
phase47 end_date
`next_run_at` keeps its name, its DATETIME type and its index — it remains the
due datetime, and is ST's `next_due_date` by another name. All recurrence maths
happens on its DATE part; the TIME part is preserved across roll-forwards
(defaulting to 06:00, the hour `_compute_next_run()` has always used) so the
auto-mode cron keeps firing at the same time of day.
Two dates, deliberately distinct:
next_run_at — mutable state. The next occurrence. Rewritten by fulfill()
after every completed inspection.
end_date — fixed boundary. The last date an occurrence may fall on, set by
the manager and never rewritten. NULL = repeat indefinitely.
"""
import calendar
from datetime import date, datetime, time, timedelta
from app import db
from app.utils.time_utils import now_eastern
# Every frequency this table accepts (phase45). 'bi-annually' is every 6 months.
FREQUENCY_CHOICES = ('once', 'daily', 'weekly', 'monthly',
'quarterly', 'bi-annually', 'annually')
# Recurring frequencies that advance by whole calendar months.
_MONTH_STEPS = {
'monthly': 1,
'quarterly': 3,
'bi-annually': 6,
'annually': 12,
}
# Monthly recurrence styles (phase46). Stored as VARCHAR, not ENUM, so adding a
# style later needs no 3-step MySQL ENUM migration.
MONTH_MODE_DAY = 'day_of_month' # "the 15th of every month"
MONTH_MODE_NTH = 'nth_weekday' # "the 2nd Tuesday of every month"
# Python weekday numbering: Monday=0 … Sunday=6 (matches date.weekday()).
WEEKDAY_NAMES = ('Monday', 'Tuesday', 'Wednesday', 'Thursday',
'Friday', 'Saturday', 'Sunday')
WEEKDAY_ABBREV = ('Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun')
# nth_week: 14 are literal, 5 means "5th (or last if the month is short)",
# -1 means "last" explicitly.
NTH_WEEK_LABELS = {1: '1st', 2: '2nd', 3: '3rd', 4: '4th', 5: '5th', -1: 'Last'}
# The hour auto-mode schedules have always been due at (see _compute_next_run()
# in app/routes/inspection_schedules.py). Used when a schedule has no
# next_run_at yet and therefore no time-of-day to preserve.
DEFAULT_RUN_HOUR = 6
def _last_day_of(year, month):
return calendar.monthrange(year, month)[1]
def _shift_month(year, month, n=1):
"""Return (year, month) shifted by *n* months."""
idx = year * 12 + (month - 1) + n
return idx // 12, idx % 12 + 1
def _nth_weekday_of(year, month, weekday, nth):
"""Date of the *nth* *weekday* in a month.
``nth == -1`` means the last one. A requested 5th occurrence that does not
exist falls back to the 4th, so every month yields a valid date.
"""
last = _last_day_of(year, month)
if nth == -1:
d = date(year, month, last)
return d - timedelta(days=(d.weekday() - weekday) % 7)
first = date(year, month, 1)
day = 1 + ((weekday - first.weekday()) % 7) + (nth - 1) * 7
while day > last:
day -= 7
return date(year, month, day)
class InspectionSchedule(db.Model):
__tablename__ = 'inspection_schedules'
@@ -56,9 +137,11 @@ class InspectionSchedule(db.Model):
nullable=False
)
# daily | weekly | monthly | quarterly — mirrors InspectionTemplate.frequency
# once | daily | weekly | monthly | quarterly | bi-annually | annually
# (phase45 — 'once', 'bi-annually' and 'annually' added; the original four
# values are unchanged, so no existing row is affected.)
frequency = db.Column(
db.Enum('daily', 'weekly', 'monthly', 'quarterly'),
db.Enum(*FREQUENCY_CHOICES),
nullable=False, default='weekly'
)
active = db.Column(db.Boolean, nullable=False, default=True)
@@ -76,6 +159,30 @@ class InspectionSchedule(db.Model):
last_run_at = db.Column(db.DateTime, nullable=True) # last successful materialisation
next_run_at = db.Column(db.DateTime, nullable=True) # when the next inspection is due
# ── Recurrence detail (phase46) ──────────────────────────────────────────
# weekly : CSV of Python weekday ints, e.g. '0,2,4' = Mon/Wed/Fri.
# NULL/empty falls back to the legacy "every 7 days" behaviour.
# monthly and the other month-stepping frequencies:
# month_mode picks which pair of columns applies —
# MONTH_MODE_DAY → day_of_month; MONTH_MODE_NTH → nth_week + nth_weekday.
# NULL falls back to the legacy "same day next period" behaviour.
# Every pre-phase46 row keeps NULLs here and therefore keeps its exact
# current cadence.
weekdays = db.Column(db.String(20), nullable=True)
month_mode = db.Column(db.String(20), nullable=True)
day_of_month = db.Column(db.SmallInteger, nullable=True)
nth_week = db.Column(db.SmallInteger, nullable=True)
nth_weekday = db.Column(db.SmallInteger, nullable=True)
# ── End-date boundary (phase47) ──────────────────────────────────────────
# Fixed boundary set by the manager, never rewritten by the app — unlike
# next_run_at, which fulfill() advances after every completed inspection.
# NULL = repeat indefinitely, which is what every pre-phase47 row is. Only
# meaningful for recurring schedules; the create/edit routes force it to
# NULL when frequency == 'once'. Applies to BOTH modes: without it an auto
# schedule would keep materialising inspections past its boundary forever.
end_date = db.Column(db.Date, nullable=True)
# phase43 — plan mode bookkeeping
last_completed_at = db.Column(db.DateTime, nullable=True)
# Per-occurrence reminder de-dup flags; reset when a recurring schedule rolls forward.
@@ -91,10 +198,13 @@ class InspectionSchedule(db.Model):
creator = db.relationship('User', foreign_keys=[created_by])
FREQUENCY_LABELS = {
'daily': 'Daily',
'weekly': 'Weekly',
'monthly': 'Monthly',
'quarterly': 'Quarterly',
'once': 'One-time',
'daily': 'Daily',
'weekly': 'Weekly',
'monthly': 'Monthly',
'quarterly': 'Quarterly',
'bi-annually': 'Every 6 months',
'annually': 'Annually',
}
@property
@@ -113,18 +223,223 @@ class InspectionSchedule(db.Model):
today = today or now_eastern().date()
return self.next_run_at.date() < today
# ── Recurrence accessors (phase46) ───────────────────────────────────────
@property
def weekday_list(self):
"""Selected weekdays as a sorted list of ints (Mon=0). [] if unset."""
if not self.weekdays:
return []
out = set()
for part in str(self.weekdays).split(','):
part = part.strip()
if part.lstrip('-').isdigit() and 0 <= int(part) <= 6:
out.add(int(part))
return sorted(out)
def set_weekdays(self, values):
"""Store an iterable of weekday ints as the CSV column (None if empty)."""
clean = sorted({int(v) for v in (values or []) if 0 <= int(v) <= 6})
self.weekdays = ','.join(str(v) for v in clean) or None
@property
def recurrence_label(self):
"""Human summary of the recurrence rule, e.g. 'Weekly · Mon, Wed, Fri'."""
base = self.frequency_label
if self.frequency == 'weekly':
days = self.weekday_list
if days:
return f"{base} · {', '.join(WEEKDAY_ABBREV[d] for d in days)}"
elif self.frequency in _MONTH_STEPS:
if self.month_mode == MONTH_MODE_NTH and self.nth_week and self.nth_weekday is not None:
nth = NTH_WEEK_LABELS.get(self.nth_week, str(self.nth_week))
return f'{base} · {nth} {WEEKDAY_NAMES[self.nth_weekday]}'
if self.day_of_month:
return f'{base} · day {self.day_of_month}'
return base
# ── Date arithmetic (phase46) ────────────────────────────────────────────
@property
def run_time(self):
"""Time-of-day this schedule is due at.
Preserved across roll-forwards so an auto schedule keeps materialising
at the hour it always has. Falls back to DEFAULT_RUN_HOUR for a schedule
that has no next_run_at yet.
"""
if self.next_run_at is not None:
return self.next_run_at.time()
return time(hour=DEFAULT_RUN_HOUR)
def set_next_run_date(self, d):
"""Set next_run_at to date *d* keeping the current time-of-day."""
if d is None:
self.next_run_at = None
else:
self.next_run_at = datetime.combine(d, self.run_time)
@staticmethod
def _add_interval(d, frequency):
"""Return date *d* advanced by one plain interval of *frequency*.
Fallback used when no day-of-week / day-of-month detail is configured
(every pre-phase46 row). Prefer :meth:`next_occurrence_after`.
"""
if frequency == 'daily':
return d + timedelta(days=1)
if frequency == 'weekly':
return d + timedelta(weeks=1)
step = _MONTH_STEPS.get(frequency)
if step:
year, month = _shift_month(d.year, d.month, step)
return date(year, month, min(d.day, _last_day_of(year, month)))
return d # 'once' has no next interval
def next_occurrence_after(self, d):
"""First occurrence strictly after date *d*, honouring the day rules."""
if self.frequency == 'weekly':
days = self.weekday_list
if days:
for step in range(1, 8):
cand = d + timedelta(days=step)
if cand.weekday() in days:
return cand
elif self.frequency in _MONTH_STEPS:
year, month = _shift_month(d.year, d.month, _MONTH_STEPS[self.frequency])
if self.month_mode == MONTH_MODE_NTH and self.nth_week and self.nth_weekday is not None:
return _nth_weekday_of(year, month, self.nth_weekday, self.nth_week)
if self.day_of_month:
return date(year, month, min(self.day_of_month, _last_day_of(year, month)))
return self._add_interval(d, self.frequency)
def align_due_date(self, d):
"""Snap *d* forward to the first date on/after it that fits the rule.
Lets a manager pick any start date and still get, say, Mon/Wed/Fri:
picking a Tuesday for a Mon/Wed/Fri schedule yields that Wednesday.
"""
if self.frequency == 'weekly':
days = self.weekday_list
if days:
for step in range(0, 7):
cand = d + timedelta(days=step)
if cand.weekday() in days:
return cand
elif self.frequency in _MONTH_STEPS:
if self.month_mode == MONTH_MODE_NTH and self.nth_week and self.nth_weekday is not None:
cand = _nth_weekday_of(d.year, d.month, self.nth_weekday, self.nth_week)
elif self.day_of_month:
cand = date(d.year, d.month,
min(self.day_of_month, _last_day_of(d.year, d.month)))
else:
return d
if cand < d:
return self.next_occurrence_after(cand)
return cand
return d
# ── End-date boundary (phase47) ──────────────────────────────────────────
def is_within_end_date(self, d):
"""True if date *d* is on or before the end date (inclusive).
No end date means the schedule repeats indefinitely, so every date
qualifies.
"""
return self.end_date is None or d <= self.end_date
@property
def is_expired(self):
"""True once the end date has passed.
Independent of `active`: a schedule can be inactive because it expired
or because a manager paused it, and the list view distinguishes the two.
Compares against the *end date* rather than next_run_at, which may have
been advanced past the boundary by fulfill().
"""
if self.end_date is None:
return False
return self.end_date < now_eastern().date()
def expire_if_past_end_date(self, today=None):
"""Deactivate a schedule whose end date has passed. Caller commits.
Returns True if this call changed anything. Needed because a schedule
can reach its end date *without ever being completed* — fulfill() never
runs, so the boundary would otherwise be checked nowhere and the cron
would keep materialising (auto) or re-alerting as overdue (plan)
forever. Called from the /run cron endpoint.
"""
today = today or now_eastern().date()
if self.active and self.end_date is not None and self.end_date < today:
self.active = False
return True
return False
# ── Roll-forward ─────────────────────────────────────────────────────────
def advance_due_date(self, now=None):
"""Move next_run_at to the first occurrence after today. Caller commits.
Shared by fulfill() (plan mode, the inspector submitted the inspection)
and the cron materialiser (auto mode, an occurrence was produced), so
both modes obey the same recurrence rules and the same end-date
boundary. A 'once' schedule deactivates and its due date is left where
it is. Returns the new due date, or None for 'once'.
Advances from the current due date rather than from now, so a Mon/Wed/Fri
schedule dealt with late stays on Mon/Wed/Fri.
"""
now = now or now_eastern()
if self.frequency == 'once':
self.active = False
return None
today = now.date()
base = self.due_date or today
nxt = self.next_occurrence_after(base)
guard = 0
# Guard stops a misconfigured row spinning; 400 covers a daily schedule
# left untouched for over a year.
while nxt <= today and guard < 400:
nxt = self.next_occurrence_after(nxt)
guard += 1
self.set_next_run_date(nxt)
# Past the manager's boundary: this was the last occurrence. next_run_at
# is left at the computed value rather than clamped, so the row still
# shows which occurrence it stopped before.
if not self.is_within_end_date(nxt):
self.active = False
return nxt
def fulfill(self, next_run_fn=None):
"""Mark this occurrence complete. Caller commits.
Recurring schedules roll their due date forward past today and reset the
reminder flags; MT has no 'once' frequency, so a schedule stays active.
`next_run_fn(frequency, from_dt)` computes the next due datetime — the
route passes `_compute_next_run` so the cadence math lives in one place.
One-time schedules deactivate. Recurring ones roll their due date
forward past today, honouring the phase46 day rules, and reset the
reminder flags. A recurring schedule whose next occurrence would fall
past its end date deactivates instead (phase47).
`next_run_fn` is accepted and IGNORED, retained only so the existing
`fulfill(next_run_fn=_compute_next_run)` call sites in
routes/inspections.py and api/inspections.py keep working unchanged.
Before phase46 the cadence maths lived in the route and omitting this
argument silently left next_run_at untouched — leaving the schedule
perpetually due. The maths now lives here, on the object that owns the
recurrence columns, so that failure mode is unreachable.
"""
now = now_eastern()
self.last_completed_at = now
if next_run_fn is not None:
self.next_run_at = next_run_fn(self.frequency, now)
self.advance_due_date(now)
if not self.active:
# 'once', or the roll-forward crossed the end date. Either way this
# was the last occurrence — leave the reminder flags set so nothing
# re-fires against a closed schedule.
return
self.advance_notified = False
self.due_notified = False
self.overdue_notified = False