""" 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'')