From d44d76170697693bf8f92ed89d19dbf736787e0d Mon Sep 17 00:00:00 2001 From: NguyenND Date: Fri, 17 Jul 2026 11:36:05 -0400 Subject: [PATCH] Jul 17 - Fill the gaps between Single-tenant mode and Multi-tenant mode - MT4 --- app/models/inspection.py | 7 + app/models/inspection_schedule.py | 68 +++++- app/routes/dashboard.py | 24 ++ app/routes/inspection_schedules.py | 228 +++++++++++++++++- app/routes/inspections.py | 14 ++ app/templates/dashboard.html | 48 ++++ app/templates/inspection_schedules/form.html | 29 ++- app/templates/inspection_schedules/index.html | 38 ++- .../versions/phase43_schedule_plan_fields.py | 120 +++++++++ tests/test_inspection_schedules.py | 8 +- 10 files changed, 565 insertions(+), 19 deletions(-) create mode 100644 migrations/versions/phase43_schedule_plan_fields.py diff --git a/app/models/inspection.py b/app/models/inspection.py index 0789cc2..d22d98a 100644 --- a/app/models/inspection.py +++ b/app/models/inspection.py @@ -57,6 +57,13 @@ class Inspection(db.Model): template_id = db.Column(db.Integer, db.ForeignKey('inspection_templates.id'), nullable=False) facility_id = db.Column(db.Integer, db.ForeignKey('facilities.id'), nullable=False) area_id = db.Column(db.Integer, db.ForeignKey('areas.id')) + # phase43: set when this inspection was started from / materialised by a + # schedule. ON DELETE SET NULL — deleting a schedule never deletes history. + inspection_schedule_id = db.Column( + db.Integer, + db.ForeignKey('inspection_schedules.id', ondelete='SET NULL'), + nullable=True, index=True + ) inspector_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) inspection_date = db.Column(db.DateTime, nullable=False, default=now_eastern) overall_score = db.Column(db.Numeric(5, 2)) diff --git a/app/models/inspection_schedule.py b/app/models/inspection_schedule.py index ac404d8..64f26f4 100644 --- a/app/models/inspection_schedule.py +++ b/app/models/inspection_schedule.py @@ -13,6 +13,20 @@ 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 @@ -49,6 +63,11 @@ class InspectionSchedule(db.Model): ) 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 @@ -57,6 +76,13 @@ 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 + # 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]) @@ -64,5 +90,45 @@ class InspectionSchedule(db.Model): 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'' + return (f'') diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index 64211ae..d41d22a 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -344,8 +344,32 @@ def index(): .all() ) + # ── Scheduled inspections (phase43): upcoming / overdue ─────────────────── + # Plan-mode only: 'auto' schedules materialise themselves into the + # inspections list, so surfacing them here would double-report the work. + sched_upcoming = [] + sched_overdue_count = 0 + if not is_customer: + from app.models.inspection_schedule import InspectionSchedule + _today = now_eastern().date() + _sq = InspectionSchedule.query.filter( + InspectionSchedule.active.is_(True), + InspectionSchedule.mode == 'plan', + ) + if is_inspector: + _sq = _sq.filter(InspectionSchedule.inspector_id == current_user.id) + _all_sched = _sq.order_by(InspectionSchedule.next_run_at.asc()).all() + sched_overdue_count = sum(1 for s in _all_sched if s.is_overdue(_today)) + # Upcoming = due today through the next 7 days (overdue shown separately) + sched_upcoming = [ + s for s in _all_sched + if s.next_run_at and _today <= s.next_run_at.date() <= _today + timedelta(days=7) + ][:8] + return render_template( 'dashboard.html', + sched_upcoming = sched_upcoming, + sched_overdue_count = sched_overdue_count, today_inspections = today_inspections, completed_today = completed_today, open_issues = open_issues, diff --git a/app/routes/inspection_schedules.py b/app/routes/inspection_schedules.py index 7d0908b..f3ea7ba 100644 --- a/app/routes/inspection_schedules.py +++ b/app/routes/inspection_schedules.py @@ -2,7 +2,19 @@ app/routes/inspection_schedules.py ----------------------------------- CRUD management for recurring InspectionSchedule configs + a cron-triggered -materialisation endpoint (phase34). +materialisation/reminder endpoint (phase34, extended phase43). + +Two modes per schedule (phase43): + auto — cron materialises an in_progress Inspection at next_run_at and notifies + the inspector. This is phase34's behaviour and remains the default. + plan — nothing is materialised; the assigned inspector clicks "Start" on the + schedules page, which creates the Inspection linked back to the + schedule. Reminders fire the day before, on the due date, and once + overdue (to admin/director). Completing it rolls the schedule forward. + +The /run cron endpoint does both: it materialises due `auto` schedules AND +dispatches reminders for `plan` schedules, so the existing crontab line needs no +change. Management is admin / director / project_manager (@project_manager_required), mirroring who may start inspections. The /run route is token-protected with the @@ -38,6 +50,7 @@ logger = logging.getLogger(__name__) bp = Blueprint('inspection_schedules', __name__, url_prefix='/inspection-schedules') _FREQUENCIES = ('daily', 'weekly', 'monthly', 'quarterly') +_MODES = ('auto', 'plan') # ── Helpers ─────────────────────────────────────────────────────────────────── @@ -69,6 +82,29 @@ def _active_inspectors(): ).order_by(User.full_name, User.username).all() +def _notify_assignee(schedule: InspectionSchedule, reassigned: bool = False): + """Tell the assigned inspector a schedule was assigned (or reassigned) to them. + + No-op when there is no active inspector. Caller commits. (phase43) + """ + inspector = schedule.inspector + if not inspector or not inspector.active: + return + fac = schedule.facility.name if schedule.facility else '—' + tpl = schedule.template.name if schedule.template else '—' + verb = 'reassigned to you' if reassigned else 'assigned to you' + due = schedule.next_run_at.strftime('%b %d, %Y') if schedule.next_run_at else 'soon' + notify( + inspector, + title = f'Scheduled inspection {verb} — {fac}', + body = (f'A "{tpl}" inspection at {fac} has been {verb} ' + f'({schedule.frequency_label.lower()}), due {due}.'), + link = url_for('inspection_schedules.index'), + event_type = EVENT_INSPECTION_SCHEDULED, + send_email = True, + ) + + def _materialise(schedule: InspectionSchedule, when: datetime) -> Inspection: """Create an in_progress Inspection from a schedule and notify the inspector. @@ -82,7 +118,8 @@ def _materialise(schedule: InspectionSchedule, when: datetime) -> Inspection: inspector_id = schedule.inspector_id, inspection_date = when, status = 'in_progress', - notes = None, + notes = schedule.notes, + inspection_schedule_id = schedule.id, # phase43 — link back to the plan ) db.session.add(inspection) db.session.flush() # assign inspection.id without committing @@ -106,13 +143,29 @@ def _materialise(schedule: InspectionSchedule, when: datetime) -> Inspection: @bp.route('/') @login_required -@project_manager_required def index(): - schedules = InspectionSchedule.query.order_by( - InspectionSchedule.active.desc(), InspectionSchedule.name + """Schedule list. + + phase43: no longer @project_manager_required — an inspector must be able to + see and Start their own plan-mode schedules. Inspectors see ONLY their own; + customers are barred; managers see everything, exactly as before. All + mutating routes below keep @project_manager_required. + """ + if current_user.role == 'customer': + abort(403) + + q = InspectionSchedule.query + if current_user.role == 'inspector': + q = q.filter(InspectionSchedule.inspector_id == current_user.id) + + schedules = q.order_by( + InspectionSchedule.active.desc(), + InspectionSchedule.next_run_at.asc(), + InspectionSchedule.name, ).all() + now = now_eastern() return render_template('inspection_schedules/index.html', - schedules=schedules, now=now_eastern()) + schedules=schedules, now=now, today=now.date()) def _form_choices(): @@ -135,6 +188,8 @@ def create(): area_id = request.form.get('area_id', type=int) or None inspector_id = request.form.get('inspector_id', type=int) frequency = request.form.get('frequency', 'weekly') + mode = request.form.get('mode', 'auto') + notes = request.form.get('notes', '').strip() or None errors = [] if not name: @@ -147,6 +202,8 @@ def create(): errors.append('Please choose a valid inspector.') if frequency not in _FREQUENCIES: errors.append('Invalid frequency.') + if mode not in _MODES: + errors.append('Invalid mode.') if errors: for e in errors: @@ -163,6 +220,8 @@ def create(): area_id = area_id, inspector_id = inspector_id, frequency = frequency, + mode = mode, + notes = notes, active = True, created_by = current_user.id, created_at = now_eastern(), @@ -171,7 +230,12 @@ def create(): db.session.add(schedule) db.session.commit() log_action(ACTION_CREATE, 'InspectionSchedule', schedule.id, schedule.name, - f'frequency={frequency}; template_id={template_id}; facility_id={facility_id}') + f'frequency={frequency}; mode={mode}; template_id={template_id}; ' + f'facility_id={facility_id}') + # phase43: tell the inspector it's theirs (plan mode has no materialised + # inspection to announce itself). + _notify_assignee(schedule) + db.session.commit() flash(f'Inspection schedule "{schedule.name}" created.', 'success') return redirect(url_for('inspection_schedules.index')) @@ -191,6 +255,7 @@ def edit(schedule_id): templates, facilities, inspectors = _form_choices() if request.method == 'POST': + old_inspector_id = schedule.inspector_id schedule.name = request.form.get('name', '').strip() or schedule.name template_id = request.form.get('template_id', type=int) facility_id = request.form.get('facility_id', type=int) @@ -206,13 +271,27 @@ def edit(schedule_id): schedule.area_id = request.form.get('area_id', type=int) or None if frequency in _FREQUENCIES: schedule.frequency = frequency + mode = request.form.get('mode', schedule.mode) + if mode in _MODES: + schedule.mode = mode + schedule.notes = request.form.get('notes', '').strip() or None schedule.active = bool(request.form.get('active')) # Recompute the next run from now against the (possibly changed) cadence. schedule.next_run_at = _compute_next_run(schedule.frequency) + # New occurrence -> the previous occurrence's reminders no longer apply. + schedule.advance_notified = False + schedule.due_notified = False + schedule.overdue_notified = False db.session.commit() log_action(ACTION_UPDATE, 'InspectionSchedule', schedule.id, schedule.name, - f'frequency={schedule.frequency}; active={schedule.active}') + f'frequency={schedule.frequency}; mode={schedule.mode}; ' + f'active={schedule.active}') + + # phase43: notify on (re)assignment to a different inspector. + if schedule.active and schedule.inspector_id and schedule.inspector_id != old_inspector_id: + _notify_assignee(schedule, reassigned=True) + db.session.commit() flash(f'Inspection schedule "{schedule.name}" updated.', 'success') return redirect(url_for('inspection_schedules.index')) @@ -258,6 +337,55 @@ def run_now(schedule_id): return redirect(url_for('inspection_schedules.index')) +# ── Start (plan mode: create the planned inspection and open the execute flow) ─ + +@bp.route('//start') +@login_required +def start(schedule_id): + """Start the inspection this schedule plans for (phase43). + + Deliberately NOT @project_manager_required: the whole point is that the + assigned inspector starts their own scheduled work. Customers are barred; + an inspector may only start their own schedule; managers may start any. + """ + schedule = db.session.get(InspectionSchedule, schedule_id) + if schedule is None: + abort(404) + if current_user.role == 'customer': + abort(403) + if current_user.role == 'inspector' and schedule.inspector_id != current_user.id: + abort(403) + + if not schedule.active: + flash('This schedule is no longer active.', 'warning') + return redirect(url_for('inspection_schedules.index')) + + template = schedule.template + if template is None or not template.get_form_schema(): + flash('The template for this schedule has no form fields yet.', 'warning') + return redirect(url_for('inspection_schedules.index')) + + inspection = Inspection( + template_id = schedule.template_id, + facility_id = schedule.facility_id, + area_id = schedule.area_id, + inspector_id = current_user.id, + inspection_date = now_eastern(), + status = 'in_progress', + notes = schedule.notes, + inspection_schedule_id = schedule.id, + ) + db.session.add(inspection) + db.session.commit() + log_action(ACTION_CREATE, 'Inspection', inspection.id, + f'{inspection.template.name} @ {inspection.facility.name}', + f'started from inspection_schedule_id={schedule.id}') + logger.info('INSPECTION SCHEDULE | start | schedule=%s | inspection=%s | by=%s', + schedule.id, inspection.id, current_user.username) + flash('Inspection started from schedule. Complete and submit the form below.', 'info') + return redirect(url_for('inspections.execute', inspection_id=inspection.id)) + + # ── Cron endpoint ───────────────────────────────────────────────────────────── @bp.route('/run', methods=['POST']) @@ -276,7 +404,10 @@ def run(): now = now_eastern() schedules = InspectionSchedule.query.filter_by(active=True).all() - due = [s for s in schedules if s.next_run_at is None or s.next_run_at <= now] + # Only 'auto' schedules materialise. 'plan' schedules wait for the inspector + # to click Start; they get reminders instead (below). + auto = [s for s in schedules if s.mode != 'plan'] + due = [s for s in auto if s.next_run_at is None or s.next_run_at <= now] created = 0 for schedule in due: @@ -295,5 +426,80 @@ def run(): schedule.id, exc) db.session.commit() - logger.info('INSPECTION SCHEDULES CRON | due=%s | created=%s', len(due), created) - return jsonify({'ok': True, 'due': len(due), 'created': created}) + sent = _dispatch_reminders([s for s in schedules if s.mode == 'plan'], now) + + logger.info('INSPECTION SCHEDULES CRON | due=%s | created=%s | reminders=%s', + len(due), created, sent) + return jsonify({'ok': True, 'due': len(due), 'created': created, 'reminders': sent}) + + +def _dispatch_reminders(plans, now): + """Advance / due / overdue reminders for plan-mode schedules (phase43). + + Each fires at most once per occurrence via the *_notified flags, which reset + when the schedule rolls forward in fulfill(). Commits. + """ + today = now.date() + sent = {'advance': 0, 'due': 0, 'overdue': 0} + + managers = User.query.filter( + User.role.in_(['admin', 'director']), User.active.is_(True) + ).all() + + for s in plans: + if s.next_run_at is None: + continue + due_date = s.next_run_at.date() + inspector = s.inspector + link = url_for('inspection_schedules.index') + fac_name = s.facility.name if s.facility else '—' + tpl_name = s.template.name if s.template else '—' + + # Advance reminder — the day before it's due + if (not s.advance_notified and inspector and inspector.active + and due_date == today + timedelta(days=1)): + notify( + inspector, + title = f'Inspection due tomorrow — {fac_name}', + body = (f'Reminder: a "{tpl_name}" inspection at {fac_name} ' + f'is scheduled for tomorrow ({due_date:%b %d, %Y}).'), + link = link, + event_type = EVENT_INSPECTION_SCHEDULED, + send_email = True, + ) + s.advance_notified = True + sent['advance'] += 1 + + # Due reminder — on/after the due date + if (not s.due_notified and inspector and inspector.active + and due_date <= today): + notify( + inspector, + title = f'Inspection due today — {fac_name}', + body = (f'A "{tpl_name}" inspection at {fac_name} is due ' + f'({due_date:%b %d, %Y}). Please complete it.'), + link = link, + event_type = EVENT_INSPECTION_SCHEDULED, + send_email = True, + ) + s.due_notified = True + sent['due'] += 1 + + # Overdue alert — past due and still not fulfilled -> managers + if not s.overdue_notified and due_date < today: + for m in managers: + notify( + m, + title = f'Overdue scheduled inspection — {fac_name}', + body = (f'The "{tpl_name}" inspection at {fac_name} assigned to ' + f'{inspector.display_name if inspector else "—"} was due ' + f'{due_date:%b %d, %Y} and has not been completed.'), + link = link, + event_type = EVENT_INSPECTION_SCHEDULED, + send_email = True, + ) + s.overdue_notified = True + sent['overdue'] += 1 + + db.session.commit() + return sent diff --git a/app/routes/inspections.py b/app/routes/inspections.py index b3ff1c8..b095a96 100644 --- a/app/routes/inspections.py +++ b/app/routes/inspections.py @@ -580,6 +580,20 @@ def execute(inspection_id): inspection_id = inspection.id, facility_id = inspection.facility_id, ) + # Fulfill the originating schedule, if any: roll a recurring plan's + # due date forward and reset its reminder flags. Staged in the same + # atomic commit below. (phase43) + if inspection.inspection_schedule_id: + from app.models.inspection_schedule import InspectionSchedule + from app.routes.inspection_schedules import _compute_next_run + sched = db.session.get(InspectionSchedule, inspection.inspection_schedule_id) + if sched is not None: + sched.fulfill(next_run_fn=_compute_next_run) + current_app.logger.info( + 'INSPECTION SCHEDULE | fulfilled | schedule=%s | inspection=%s | next_due=%s', + sched.id, inspection.id, sched.next_run_at, + ) + db.session.commit() # Single atomic commit: inspection fields + notification rows log_action(ACTION_UPDATE, 'Inspection', inspection.id, f'{inspection.template.name} @ {inspection.facility.name}', diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 3323479..aaebcc5 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -11,6 +11,54 @@ +{# ── Scheduled Inspections (phase43) — plan-mode, staff only ─────────────── #} +{% if current_user.role != 'customer' and (sched_upcoming or sched_overdue_count) %} +
+
+
+ Scheduled Inspections + View all +
+ {% if sched_overdue_count %} +
+ + {{ sched_overdue_count }} scheduled inspection{{ 's' if sched_overdue_count != 1 }} + {{ 'are' if sched_overdue_count != 1 else 'is' }} overdue. +
+ {% endif %} + {% if sched_upcoming %} +
+ + + + + + {% for s in sched_upcoming %} + + + + + + + + + {% endfor %} + +
ScheduleFacilityTemplateInspectorDue
{{ s.name }}{{ s.facility.name if s.facility else '—' }}{{ s.template.name if s.template else '—' }}{{ s.inspector.display_name if s.inspector else '—' }}{{ s.next_run_at.strftime('%b %d') if s.next_run_at else '—' }} + {% if current_user.role in ['admin','director','project_manager','auditor'] + or (current_user.role == 'inspector' and s.inspector_id == current_user.id) %} + Start + {% endif %} +
+
+ {% else %} +

No inspections due in the next 7 days.

+ {% endif %} +
+
+{% endif %} + {# ── Inspections section ─────────────────────────────────────────────────── #}
diff --git a/app/templates/inspection_schedules/form.html b/app/templates/inspection_schedules/form.html index 7706c94..ac56370 100644 --- a/app/templates/inspection_schedules/form.html +++ b/app/templates/inspection_schedules/form.html @@ -39,6 +39,29 @@
+
+
+ + +
+ Auto drops an in-progress inspection into the inspector's queue on + schedule. Plan assigns a due date and reminds them the day before, on + the day, and alerts managers once it's overdue. +
+
+
+ + +
Copied onto the inspection when it starts.
+
+
+
@@ -80,8 +103,12 @@

- Saving recomputes the next run from now. Next run: + Saving recomputes the next {{ 'due date' if schedule.mode == 'plan' else 'run' }} + from now, and resets this occurrence's reminders. Currently: {{ schedule.next_run_at.strftime('%Y-%m-%d %H:%M') if schedule.next_run_at else '—' }} + {% if schedule.last_completed_at %} + · last completed {{ schedule.last_completed_at.strftime('%Y-%m-%d %H:%M') }} + {% endif %}

{% endif %}
diff --git a/app/templates/inspection_schedules/index.html b/app/templates/inspection_schedules/index.html index b7dad51..f2894de 100644 --- a/app/templates/inspection_schedules/index.html +++ b/app/templates/inspection_schedules/index.html @@ -3,15 +3,24 @@ {% block content %}

Inspection Schedules

+ {% if current_user.role != 'inspector' %} New Schedule + {% endif %}

- Recurring schedules automatically create an in-progress inspection for the - assigned inspector each period. The inspector is notified and opens it from - their Inspections list to complete it. + {% if current_user.role == 'inspector' %} + Inspections scheduled for you. Auto schedules appear in your + Inspections list on their own each period; Plan schedules wait + for you to press Start. + {% else %} + Auto schedules automatically create an in-progress inspection + for the assigned inspector each period. Plan schedules assign a + due date and let the inspector press Start when they begin — with reminders the + day before, on the day, and an alert to managers once overdue. + {% endif %}

{% if schedules %} @@ -22,8 +31,8 @@ NameTemplateFacility / Area - InspectorFrequencyNext Run - Last RunStatus + InspectorFrequencyModeNext Due + Last RunStatus @@ -37,8 +46,18 @@ {{ s.inspector.display_name if s.inspector else '—' }} {{ s.frequency|title }} + + {% if s.mode == 'plan' %} + Plan + {% else %} + Auto + {% endif %} + {{ s.next_run_at.strftime('%Y-%m-%d %H:%M') if s.next_run_at else '—' }} + {% if s.is_overdue(today) %} + Overdue + {% endif %} {{ s.last_run_at.strftime('%Y-%m-%d %H:%M') if s.last_run_at else 'Never' }} @@ -48,6 +67,14 @@ {% else %}Paused{% endif %} + {% if s.active and s.mode == 'plan' + and (current_user.role != 'inspector' or s.inspector_id == current_user.id) %} + + Start + + {% endif %} + {% if current_user.role != 'inspector' %} @@ -70,6 +97,7 @@ + {% endif %} {% endfor %} diff --git a/migrations/versions/phase43_schedule_plan_fields.py b/migrations/versions/phase43_schedule_plan_fields.py new file mode 100644 index 0000000..44e566d --- /dev/null +++ b/migrations/versions/phase43_schedule_plan_fields.py @@ -0,0 +1,120 @@ +"""phase43 — plan semantics for inspection_schedules + +Extends MT's `inspection_schedules` (phase34) with the single-tenant "plan" +model (ST phase36_scheduled_inspections), rather than adding a second, competing +scheduler table. Adds: + + notes TEXT — free-text brief for the inspector + last_completed_at DATETIME — when the last occurrence was fulfilled + advance_notified BOOL — per-occurrence reminder de-dup flags, reset + due_notified BOOL when a recurring schedule rolls forward + overdue_notified BOOL + mode ENUM — 'auto' = cron materialises the Inspection + 'plan' = inspector clicks Start (ST behaviour) + + inspections.inspection_schedule_id — FK back to the originating schedule + +`next_run_at` (phase34) is reused as the due datetime — ST's `next_due_date` by +another name. No duplicate column, no renames (rule 7). + +Existing rows keep working exactly as before: `mode` defaults to 'auto', which +is the only behaviour that has ever existed in MT. Nothing flips to 'plan' +unless someone chooses it in the form. + +INFORMATION_SCHEMA-guarded throughout — safe to re-run on every tenant DB. +""" + +revision = 'phase43_schedule_plan_fields' +down_revision = 'phase42_area_qr_token' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def _column_exists(conn, table, column): + return conn.execute(sa.text( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() " + "AND TABLE_NAME = :t AND COLUMN_NAME = :c" + ), {"t": table, "c": column}).scalar() > 0 + + +def _fk_exists(conn, table, name): + return conn.execute(sa.text( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS " + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t " + "AND CONSTRAINT_NAME = :n AND CONSTRAINT_TYPE = 'FOREIGN KEY'" + ), {"t": table, "n": name}).scalar() > 0 + + +def _index_exists(conn, table, index): + return conn.execute(sa.text( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS " + "WHERE TABLE_SCHEMA = DATABASE() " + "AND TABLE_NAME = :t AND INDEX_NAME = :i" + ), {"t": table, "i": index}).scalar() > 0 + + +_NEW_COLUMNS = ( + ('notes', 'TEXT NULL'), + ('last_completed_at', 'DATETIME NULL'), + ('advance_notified', 'TINYINT(1) NOT NULL DEFAULT 0'), + ('due_notified', 'TINYINT(1) NOT NULL DEFAULT 0'), + ('overdue_notified', 'TINYINT(1) NOT NULL DEFAULT 0'), + ('mode', "ENUM('auto','plan') NOT NULL DEFAULT 'auto'"), +) + + +def upgrade(): + bind = op.get_bind() + + for name, ddl in _NEW_COLUMNS: + if not _column_exists(bind, 'inspection_schedules', name): + op.execute(sa.text( + f"ALTER TABLE inspection_schedules ADD COLUMN {name} {ddl}" + )) + + # Link a materialised/started Inspection back to its schedule. + if not _column_exists(bind, 'inspections', 'inspection_schedule_id'): + op.execute(sa.text( + "ALTER TABLE inspections ADD COLUMN inspection_schedule_id INT NULL" + )) + if not _index_exists(bind, 'inspections', 'ix_inspections_inspection_schedule_id'): + op.execute(sa.text( + "CREATE INDEX ix_inspections_inspection_schedule_id " + "ON inspections (inspection_schedule_id)" + )) + if not _fk_exists(bind, 'inspections', 'fk_inspections_inspection_schedule'): + # ON DELETE SET NULL: deleting a schedule must never delete completed + # inspection history. + op.execute(sa.text( + "ALTER TABLE inspections " + "ADD CONSTRAINT fk_inspections_inspection_schedule " + "FOREIGN KEY (inspection_schedule_id) " + "REFERENCES inspection_schedules (id) ON DELETE SET NULL" + )) + + +def downgrade(): + bind = op.get_bind() + + if _fk_exists(bind, 'inspections', 'fk_inspections_inspection_schedule'): + op.execute(sa.text( + "ALTER TABLE inspections DROP FOREIGN KEY fk_inspections_inspection_schedule" + )) + if _index_exists(bind, 'inspections', 'ix_inspections_inspection_schedule_id'): + op.execute(sa.text( + "DROP INDEX ix_inspections_inspection_schedule_id ON inspections" + )) + if _column_exists(bind, 'inspections', 'inspection_schedule_id'): + op.execute(sa.text( + "ALTER TABLE inspections DROP COLUMN inspection_schedule_id" + )) + + for name, _ddl in reversed(_NEW_COLUMNS): + if _column_exists(bind, 'inspection_schedules', name): + op.execute(sa.text( + f"ALTER TABLE inspection_schedules DROP COLUMN {name}" + )) diff --git a/tests/test_inspection_schedules.py b/tests/test_inspection_schedules.py index a265555..ee8cf8a 100644 --- a/tests/test_inspection_schedules.py +++ b/tests/test_inspection_schedules.py @@ -67,7 +67,13 @@ def test_cron_materialises_due_schedule_and_notifies(client): resp = client.post('/inspection-schedules/run', data={'token': 'test-digest'}) assert resp.status_code == 200 - assert resp.get_json() == {'ok': True, 'due': 1, 'created': 1} + # phase43 widened this response with a 'reminders' key (plan-mode schedules). + # Materialisation of 'auto' schedules is unchanged. + payload = resp.get_json() + assert payload['ok'] is True + assert payload['due'] == 1 + assert payload['created'] == 1 + assert payload['reminders'] == {'advance': 0, 'due': 0, 'overdue': 0} # A real in_progress inspection now exists for the assigned inspector. insp = Inspection.query.filter_by(inspector_id=insp_user_id).first()