135 lines
5.6 KiB
Python
135 lines
5.6 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.
|
|
"""
|
|
|
|
from app import db
|
|
from app.utils.time_utils import now_eastern
|
|
|
|
|
|
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
|
|
)
|
|
|
|
# daily | weekly | monthly | quarterly — mirrors InspectionTemplate.frequency
|
|
frequency = db.Column(
|
|
db.Enum('daily', 'weekly', 'monthly', 'quarterly'),
|
|
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)
|
|
|
|
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
|
|
|
|
# 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])
|
|
|
|
FREQUENCY_LABELS = {
|
|
'daily': 'Daily',
|
|
'weekly': 'Weekly',
|
|
'monthly': 'Monthly',
|
|
'quarterly': 'Quarterly',
|
|
}
|
|
|
|
@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
|
|
|
|
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.
|
|
"""
|
|
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_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}>')
|