Aug 4 - Update code to follow up - MT11
This commit is contained in:
@@ -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: 1–4 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
|
||||
|
||||
Reference in New Issue
Block a user