Files
JQC_multi_tenant/app/models/inspection_schedule.py
T

481 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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: 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'
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}>')