481 lines
21 KiB
Python
481 lines
21 KiB
Python
"""
|
||
app/models/inspection_schedule.py
|
||
---------------------------------
|
||
Recurring inspection schedules (phase34).
|
||
|
||
An InspectionSchedule declares that a given template should be inspected at a
|
||
given facility (optionally scoped to one area) by a given inspector on a fixed
|
||
cadence. A token-protected cron endpoint (`/inspection-schedules/run`) walks
|
||
all active, due schedules and *materialises* a real `Inspection` row in
|
||
`in_progress` status — exactly as if the inspector had clicked "Start
|
||
Inspection" — then notifies the assigned inspector. The inspector opens it from
|
||
their queue and fills it out through the normal execute flow.
|
||
|
||
This is purely additive: no existing inspection behaviour changes. A schedule is
|
||
just an automated `inspections.start()`.
|
||
|
||
phase43 adds the single-tenant "plan" semantics alongside that:
|
||
|
||
mode='auto' (default, phase34 behaviour)
|
||
Cron materialises the Inspection at next_run_at and notifies the inspector.
|
||
|
||
mode='plan' (ST behaviour)
|
||
Nothing is materialised. The schedule is a commitment with a due date; the
|
||
assigned inspector clicks "Start", which creates the Inspection linked back
|
||
via Inspection.inspection_schedule_id. Reminders fire in advance / on the
|
||
due date / once overdue. Completing the inspection calls fulfill(), which
|
||
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'
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
name = db.Column(db.String(255), nullable=False)
|
||
|
||
template_id = db.Column(
|
||
db.Integer, db.ForeignKey('inspection_templates.id', ondelete='CASCADE'),
|
||
nullable=False
|
||
)
|
||
facility_id = db.Column(
|
||
db.Integer, db.ForeignKey('facilities.id', ondelete='CASCADE'),
|
||
nullable=False
|
||
)
|
||
area_id = db.Column(
|
||
db.Integer, db.ForeignKey('areas.id', ondelete='SET NULL'),
|
||
nullable=True
|
||
)
|
||
inspector_id = db.Column(
|
||
db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'),
|
||
nullable=False
|
||
)
|
||
|
||
# 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(*FREQUENCY_CHOICES),
|
||
nullable=False, default='weekly'
|
||
)
|
||
active = db.Column(db.Boolean, nullable=False, default=True)
|
||
|
||
# phase43: 'auto' = cron materialises the inspection (phase34 behaviour,
|
||
# the default for every pre-existing row); 'plan' = the inspector starts it.
|
||
mode = db.Column(db.Enum('auto', 'plan'), nullable=False, default='auto')
|
||
notes = db.Column(db.Text, nullable=True)
|
||
|
||
# ── Follow-up link (phase48) ─────────────────────────────────────────────
|
||
# Set when this schedule was created as a follow-up of a specific completed
|
||
# inspection ("Schedule Follow-up" in the iPad's history detail — the
|
||
# deferred twin of "Re-inspect Now"). The inspection eventually started from
|
||
# this schedule inherits it as its own parent_inspection_id, so the run
|
||
# lands as a true linked re-inspection: pre-filled from the parent, and
|
||
# clearing the parent's follow_up_required on submit. NULL = an ordinary
|
||
# schedule, which is what every pre-phase48 row is.
|
||
parent_inspection_id = db.Column(
|
||
db.Integer,
|
||
# use_alter + an explicit name: inspections and inspection_schedules now
|
||
# reference each other, so metadata-driven CREATE/DROP cannot topologically
|
||
# sort them. The name matches the constraint phase48 creates, so the ORM's
|
||
# view of the schema and the migration's agree.
|
||
db.ForeignKey('inspections.id', ondelete='SET NULL',
|
||
name='fk_inspection_schedules_parent_inspection',
|
||
use_alter=True),
|
||
nullable=True, index=True,
|
||
)
|
||
|
||
created_by = db.Column(
|
||
db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'),
|
||
nullable=True
|
||
)
|
||
created_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
|
||
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.
|
||
advance_notified = db.Column(db.Boolean, nullable=False, default=False)
|
||
due_notified = db.Column(db.Boolean, nullable=False, default=False)
|
||
overdue_notified = db.Column(db.Boolean, nullable=False, default=False)
|
||
|
||
# Relationships — explicit foreign_keys because two columns point at users.id.
|
||
template = db.relationship('InspectionTemplate', foreign_keys=[template_id])
|
||
facility = db.relationship('Facility', foreign_keys=[facility_id])
|
||
area = db.relationship('Area', foreign_keys=[area_id])
|
||
inspector = db.relationship('User', foreign_keys=[inspector_id])
|
||
creator = db.relationship('User', foreign_keys=[created_by])
|
||
# phase48. Explicit foreign_keys is required, not optional: inspections and
|
||
# inspection_schedules now reference each other (Inspection
|
||
# .inspection_schedule_id points here, parent_inspection_id points back), so
|
||
# SQLAlchemy cannot infer the join for either side.
|
||
parent_inspection = db.relationship('Inspection',
|
||
foreign_keys=[parent_inspection_id])
|
||
|
||
@property
|
||
def is_follow_up(self):
|
||
"""True when this schedule was created to follow up an inspection."""
|
||
return self.parent_inspection_id is not None
|
||
|
||
FREQUENCY_LABELS = {
|
||
'once': 'One-time',
|
||
'daily': 'Daily',
|
||
'weekly': 'Weekly',
|
||
'monthly': 'Monthly',
|
||
'quarterly': 'Quarterly',
|
||
'bi-annually': 'Every 6 months',
|
||
'annually': 'Annually',
|
||
}
|
||
|
||
@property
|
||
def frequency_label(self):
|
||
return self.FREQUENCY_LABELS.get(self.frequency, self.frequency)
|
||
|
||
@property
|
||
def due_date(self):
|
||
"""The due date (date part of next_run_at), or None."""
|
||
return self.next_run_at.date() if self.next_run_at else None
|
||
|
||
def is_overdue(self, today=None):
|
||
"""True when an active schedule's due date has passed."""
|
||
if not self.active or self.next_run_at is None:
|
||
return False
|
||
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.
|
||
|
||
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
|
||
|
||
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
|
||
|
||
def __repr__(self):
|
||
return (f'<InspectionSchedule {self.id} {self.name!r} '
|
||
f'{self.frequency} mode={self.mode}>')
|