""" app/routes/scheduled_inspections.py ------------------------------------ Planned / recurring inspection assignments (phase36). Management (list/new/edit/delete) : admin, director, project_manager Start (execute the planned inspection): the assigned inspector ONLY (the person who must do it) — not managers. A manager who needs to run it assigns it to themselves. Cron reminders : POST /run?token=DIGEST_SECRET (no login) Fulfillment (marking a schedule done and rolling recurring ones forward) happens in the inspection execute route when the linked inspection is completed — see app/routes/inspections.py. """ import logging from datetime import timedelta from flask import (Blueprint, render_template, redirect, url_for, flash, request, abort, current_app) from flask_login import login_required, current_user from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired from app import db from app.models.scheduled_inspection import (ScheduledInspection, MONTH_MODE_DAY, MONTH_MODE_NTH) from app.models.facility import Facility from app.models.inspection import Inspection, InspectionTemplate from app.models.project import Project from app.models.user import User from app.utils.forms import ScheduledInspectionForm from app.utils.decorators import project_manager_required from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE from app.utils.time_utils import now_eastern from app.utils.notifications import notify from app.models.notification import EVENT_SCHEDULED_INSPECTION logger = logging.getLogger(__name__) bp = Blueprint('scheduled_inspections', __name__, url_prefix='/scheduled-inspections') # ── Email "Confirm receipt" one-click token (phase47) ───────────────────────── # A signed, stateless token (no DB column) lets the assigned inspector confirm # receipt straight from the assignment email, even when not logged in — the same # login-free pattern the public QR pages use. The token binds the schedule id to # the inspector id, so a schedule reassigned to someone else invalidates any # link that was emailed to the previous assignee. _ACK_SALT = 'scheduled-inspection-ack' _ACK_MAX_AGE = 60 * 60 * 24 * 30 # 30 days — a link older than this is expired def _ack_serializer(): return URLSafeTimedSerializer(current_app.config['SECRET_KEY'], salt=_ACK_SALT) def _make_ack_token(sched): """Signed token embedding the schedule id + the assigned inspector id.""" return _ack_serializer().dumps({'sid': sched.id, 'iid': sched.inspector_id}) def _confirm_action(sched): """`extra_action` dict for the email "Confirm receipt" button, or None when there is nothing to confirm (no inspector, or already acknowledged). Requires a request context for the external URL. Reused by the assignment email and the advance/due reminder emails so an unconfirmed inspector can always confirm from whichever email reaches them.""" if not sched.inspector_id or sched.is_acknowledged: return None return { 'label': 'Confirm receipt', 'url': url_for('scheduled_inspections.confirm_email', token=_make_ack_token(sched), _external=True), } def _populate_choices(form): # facility_id choices are ALL active facilities so POST validation passes # regardless of which contract the UI-only contract selector had chosen # (CLAUDE.md rule 61). The contract selector narrows the list client-side. facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all() templates = (InspectionTemplate.query .filter_by(active=True).order_by(InspectionTemplate.name).all()) inspectors = (User.query.filter(User.role.in_(User.INSPECTOR_ROLES), User.active == True) .order_by(User.username).all()) form.facility_id.choices = [(f.id, f.name) for f in facilities] form.template_id.choices = [(t.id, t.name) for t in templates] form.inspector_id.choices = [(u.id, u.display_name) for u in inspectors] def _active_contracts(): return Project.query.filter_by(active=True).order_by(Project.name).all() def _notify_assignee(sched, reassigned=False): """Send an immediate in-app + email notification to the assigned inspector that a scheduled inspection was assigned (or reassigned) to them. No-op when there is no active inspector. Caller commits.""" inspector = sched.inspector if not inspector or not inspector.active: return fac = sched.facility.name if sched.facility else '—' tpl = sched.template.name if sched.template else '—' verb = 'reassigned to you' if reassigned else 'assigned to you' notify( recipient = inspector, title = f'Scheduled inspection {verb} — {fac}', body = (f'A "{tpl}" inspection at {fac} has been {verb} ' f'({sched.frequency_label.lower()}), due ' f'{sched.next_due_date:%b %d, %Y}. ' f'Please confirm you received this request.'), link = url_for('scheduled_inspections.index'), event_type = EVENT_SCHEDULED_INSPECTION, send_email = True, extra_action = _confirm_action(sched), ) def _notify_creator_acknowledged(sched): """Notify the schedule's creator that the assigned inspector has confirmed receipt of the request. No-op when there is no creator, the creator is inactive, or the creator IS the inspector (self-assigned). Caller commits.""" creator = sched.creator if not creator or not creator.active or creator.id == sched.inspector_id: return fac = sched.facility.name if sched.facility else '—' tpl = sched.template.name if sched.template else '—' who = sched.inspector.display_name if sched.inspector else 'The inspector' notify( recipient = creator, title = f'Inspector confirmed receipt — {fac}', body = (f'{who} confirmed receipt of the "{tpl}" scheduled ' f'inspection at {fac} ({sched.frequency_label.lower()}), ' f'due {sched.next_due_date:%b %d, %Y}.'), link = url_for('scheduled_inspections.index'), event_type = EVENT_SCHEDULED_INSPECTION, send_email = True, ) def _apply_recurrence(sched, form): """Copy the recurrence block for the chosen frequency onto *sched* and clear the blocks that no longer apply, then snap next_due_date onto the rule. Keeping the unused columns NULL means `recurrence_label` and the date math never read stale settings after a frequency change.""" sched.frequency = form.frequency.data if sched.frequency == 'weekly': sched.set_weekdays(form.weekdays.data) else: sched.weekdays = None if sched.frequency == 'monthly': sched.month_mode = form.month_mode.data or MONTH_MODE_DAY if sched.month_mode == MONTH_MODE_NTH: sched.day_of_month = None sched.nth_week = form.nth_week.data sched.nth_weekday = form.nth_weekday.data else: sched.day_of_month = form.day_of_month.data sched.nth_week = None sched.nth_weekday = None else: sched.month_mode = sched.day_of_month = None sched.nth_week = sched.nth_weekday = None # End date (phase44) — a boundary, not a cadence setting. A one-time # schedule has none: it ends by deactivating when it is completed. sched.end_date = form.end_date.data if sched.frequency != 'once' else None # Snap the picked date forward onto the first matching occurrence. sched.next_due_date = sched.align_due_date(form.next_due_date.data) def _reject_if_past_end_date(sched, form): """True (and a form error set) if the aligned first occurrence falls past the end date. The form already rejects an end date earlier than the *picked* due date, but align_due_date() can push that date forward onto the recurrence rule — pick a Tuesday for a Mon/Wed/Fri schedule and the first occurrence is Wednesday. Without this check that combination would save as active with no occurrence it is ever allowed to run. """ if sched.is_within_end_date(sched.next_due_date): return False form.end_date.errors.append( f'With this recurrence the first occurrence falls on ' f'{sched.next_due_date:%b %d, %Y}, after the end date.') return True def _reject_template_not_on_contract(form): """True (and a form error set) if the chosen form isn't usable at the chosen facility (phase52). The template choices stay unrestricted for the same reason the facility choices do (rule 61 — the contract selector is UI-only, so POST validation must not depend on it). That means the contract restriction has to be enforced HERE, after validation, against the facility actually picked. Without this a manager could schedule one customer's bespoke form against another customer's facility, and the mismatch would only surface when the inspector opened it. """ facility = db.session.get(Facility, form.facility_id.data) template = db.session.get(InspectionTemplate, form.template_id.data) if template is None or facility is None: return False if template.available_for_project(facility.project_id): return False form.template_id.errors.append( f'"{template.name}" is not available on ' f'{facility.project.name if facility.project else "this facility\'s contract"}. ' f'Choose a form attached to that contract, or a shared form.') return True def _open_inspection_ids(schedules): """{schedule_id: inspection_id} for schedules with an inspection already in progress, so the UI offers Continue instead of a duplicate Start.""" ids = [s.id for s in schedules if s.id] if not ids: return {} rows = (Inspection.query .filter(Inspection.scheduled_inspection_id.in_(ids), Inspection.status == 'in_progress') .order_by(Inspection.id.desc()) .all()) return {r.scheduled_inspection_id: r.id for r in rows} def _selected_project_id(form): """Contract of the submitted facility (for restoring the selector on re-render), or None.""" if form.facility_id.data: fac = db.session.get(Facility, form.facility_id.data) if fac: return fac.project_id return None # ── List ────────────────────────────────────────────────────────────────────── @bp.route('/') @login_required def index(): if current_user.role == 'customer': abort(403) # Two tabs (phase47): Pending = schedules still producing occurrences # (active); Completed = closed schedules (fulfilled one-times, ended # recurring, or manually deactivated). The partition is exhaustive and # non-overlapping, so every schedule appears in exactly one tab; the in-row # Status badge (Active / Ended / Inactive) disambiguates the closed ones. tab = request.args.get('tab', 'pending') if tab not in ('pending', 'completed'): tab = 'pending' today = now_eastern().date() base = ScheduledInspection.query # Inspectors see only their own assignments; managers see everything. if current_user.is_inspector: base = base.filter(ScheduledInspection.inspector_id == current_user.id) pending_count = base.filter(ScheduledInspection.active.is_(True)).count() completed_count = base.filter(ScheduledInspection.active.is_(False)).count() if tab == 'pending': schedules = (base.filter(ScheduledInspection.active.is_(True)) .order_by(ScheduledInspection.next_due_date.asc()).all()) else: # Most recently completed first; NULL last_completed (e.g. manually # switched off before ever running) sorts last under MySQL DESC. schedules = (base.filter(ScheduledInspection.active.is_(False)) .order_by(ScheduledInspection.last_completed_at.desc(), ScheduledInspection.next_due_date.desc()).all()) return render_template('scheduled_inspections/list.html', schedules=schedules, today=today, tab=tab, pending_count=pending_count, completed_count=completed_count, open_inspections=_open_inspection_ids(schedules)) # ── Create ────────────────────────────────────────────────────────────────── @bp.route('/new', methods=['GET', 'POST']) @login_required @project_manager_required def create(): form = ScheduledInspectionForm() _populate_choices(form) # On a new schedule this date IS the start; on edit it is whatever the next # occurrence happens to be. One field, two meanings — so the label follows # the context instead of saying both at once. form.next_due_date.label.text = 'Start Date' if not form.next_due_date.data: form.next_due_date.data = now_eastern().date() if form.validate_on_submit() and not _reject_template_not_on_contract(form): sched = ScheduledInspection( facility_id = form.facility_id.data, template_id = form.template_id.data, inspector_id = form.inspector_id.data, next_due_date = form.next_due_date.data, notes = (form.notes.data or '').strip() or None, active = form.active.data, created_by = current_user.id, ) _apply_recurrence(sched, form) if _reject_if_past_end_date(sched, form): # sched was never added to the session — nothing to roll back. return render_template('scheduled_inspections/form.html', form=form, title='New Scheduled Inspection', projects=_active_contracts(), selected_project_id=_selected_project_id(form)) db.session.add(sched) db.session.commit() log_action(ACTION_CREATE, 'ScheduledInspection', sched.id, f'{sched.template.name} @ {sched.facility.name}', f'freq={sched.recurrence_label}; due={sched.next_due_date}; ' f'end={sched.end_date or "—"}; ' f'inspector={sched.inspector_id}') logger.info('SCHED INSP | create | by=%s | id=%s', current_user.username, sched.id) # Notify the assigned inspector immediately. _notify_assignee(sched, reassigned=False) db.session.commit() flash('Scheduled inspection created.', 'success') return redirect(url_for('scheduled_inspections.index')) return render_template('scheduled_inspections/form.html', form=form, title='New Scheduled Inspection', projects=_active_contracts(), selected_project_id=_selected_project_id(form)) # ── Edit ────────────────────────────────────────────────────────────────────── @bp.route('//edit', methods=['GET', 'POST']) @login_required @project_manager_required def edit(schedule_id): sched = db.session.get(ScheduledInspection, schedule_id) if sched is None: abort(404) form = ScheduledInspectionForm(obj=sched) _populate_choices(form) form.next_due_date.label.text = 'Next Due Date' if request.method == 'GET': # obj= copies the raw CSV column into a multi-select field; hand it the # parsed int list instead so the checkboxes pre-tick correctly. form.weekdays.data = sched.weekday_list form.month_mode.data = sched.month_mode or MONTH_MODE_DAY if form.validate_on_submit() and not _reject_template_not_on_contract(form): old_inspector_id = sched.inspector_id sched.facility_id = form.facility_id.data sched.template_id = form.template_id.data sched.inspector_id = form.inspector_id.data sched.notes = (form.notes.data or '').strip() or None sched.active = form.active.data # Reassigning to a different inspector invalidates any prior confirmation # — the new assignee has not yet acknowledged the request (phase47). if sched.inspector_id != old_inspector_id: sched.acknowledged_at = None _apply_recurrence(sched, form) if _reject_if_past_end_date(sched, form): # sched is a persistent object and has already been mutated — discard # those pending changes before re-rendering so nothing leaks out on # the next flush. db.session.rollback() return render_template('scheduled_inspections/form.html', form=form, title='Edit Scheduled Inspection', schedule=sched, projects=_active_contracts(), selected_project_id=_selected_project_id(form)) db.session.commit() log_action(ACTION_UPDATE, 'ScheduledInspection', sched.id, f'{sched.template.name} @ {sched.facility.name}', f'freq={sched.recurrence_label}; due={sched.next_due_date}; ' f'end={sched.end_date or "—"}; ' f'active={sched.active}') # Notify the inspector if the assignment changed to them. if sched.active and sched.inspector_id and sched.inspector_id != old_inspector_id: _notify_assignee(sched, reassigned=True) db.session.commit() flash('Scheduled inspection updated.', 'success') return redirect(url_for('scheduled_inspections.index')) # Restore the contract selector: submitted facility's contract on error, # otherwise the schedule's current facility's contract. sel_pid = _selected_project_id(form) if sel_pid is None and sched.facility: sel_pid = sched.facility.project_id return render_template('scheduled_inspections/form.html', form=form, title='Edit Scheduled Inspection', schedule=sched, projects=_active_contracts(), selected_project_id=sel_pid) # ── Delete ──────────────────────────────────────────────────────────────────── @bp.route('//delete', methods=['POST']) @login_required @project_manager_required def delete(schedule_id): sched = db.session.get(ScheduledInspection, schedule_id) if sched is None: abort(404) label = f'{sched.template.name if sched.template else "?"} @ {sched.facility.name if sched.facility else "?"}' sid = sched.id db.session.delete(sched) db.session.commit() log_action(ACTION_DELETE, 'ScheduledInspection', sid, label) flash('Scheduled inspection deleted.', 'success') return redirect(url_for('scheduled_inspections.index')) # ── Start (create the planned inspection and open the execute flow) ─────────── @bp.route('//start') @login_required def start(schedule_id): sched = db.session.get(ScheduledInspection, schedule_id) if sched is None: abort(404) # Only the assigned inspector may start it — this inspection is theirs to do. # Managers (admin/director/pm) manage the schedule but do not start it for # someone else; if a manager needs to do the inspection, assign it to them. if not sched.inspector_id or sched.inspector_id != current_user.id: abort(403) if not sched.active: flash('This scheduled inspection is no longer active.', 'warning') return redirect(url_for('scheduled_inspections.index')) template = sched.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('scheduled_inspections.index')) # Already started but not submitted? Resume it rather than opening a second # inspection against the same occurrence. existing = (Inspection.query .filter_by(scheduled_inspection_id=sched.id, status='in_progress') .order_by(Inspection.id.desc()) .first()) if existing is not None: flash('Resuming the inspection you already started for this schedule.', 'info') return redirect(url_for('inspections.execute', inspection_id=existing.id)) inspection = Inspection( template_id = sched.template_id, facility_id = sched.facility_id, area_id = None, inspector_id = current_user.id, inspection_date = now_eastern(), status = 'in_progress', scheduled_inspection_id = sched.id, # phase45 — a schedule created by "Schedule Follow-up" carries the # inspection it is a follow-up of. Inheriting it here is what makes the # run a real linked re-inspection: execute() pre-fills from the parent # and submit clears the parent's follow_up_required. NULL for ordinary # schedules, which is every pre-phase45 row. parent_inspection_id = sched.parent_inspection_id, ) db.session.add(inspection) db.session.commit() log_action(ACTION_CREATE, 'Inspection', inspection.id, f'{inspection.template.name} @ {inspection.facility.name}', f'from scheduled_inspection_id={sched.id}') logger.info('SCHED INSP | start | schedule=%s | inspection=%s | by=%s', sched.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)) # ── Acknowledge (inspector confirms receipt of the request) ─────────────────── @bp.route('//acknowledge', methods=['POST']) @login_required def acknowledge(schedule_id): """The assigned inspector confirms they have received the scheduled request. Assignee-only (like Start): a manager cannot confirm on someone's behalf. Idempotent — confirming an already-confirmed schedule is a no-op. On the first confirmation the schedule's creator is notified.""" sched = db.session.get(ScheduledInspection, schedule_id) if sched is None: abort(404) if not sched.inspector_id or sched.inspector_id != current_user.id: abort(403) if _do_acknowledge(sched, current_user.username): flash('You have confirmed receipt of this scheduled inspection.', 'success') else: flash('You have already confirmed this scheduled inspection.', 'info') return redirect(url_for('scheduled_inspections.index')) def _do_acknowledge(sched, actor_username): """Stamp acknowledged_at, log the action, and notify the creator. Idempotent: returns True if this call newly confirmed, False if it was already confirmed. Shared by the logged-in POST route and the login-free email-token GET route. The caller must have verified the actor is the assigned inspector.""" if sched.acknowledged_at is not None: return False sched.acknowledged_at = now_eastern() db.session.commit() log_action(ACTION_UPDATE, 'ScheduledInspection', sched.id, f'{sched.template.name if sched.template else "?"} @ ' f'{sched.facility.name if sched.facility else "?"}', 'inspector confirmed receipt') logger.info('SCHED INSP | acknowledge | schedule=%s | by=%s', sched.id, actor_username) _notify_creator_acknowledged(sched) db.session.commit() return True # ── Confirm receipt from the assignment email (login-free, token-signed) ────── @bp.route('/confirm/') def confirm_email(token): """One-click "Confirm receipt" landing from the assignment email (phase47). Login-free: authorised by the signed token, which binds the schedule id to the inspector id it was emailed to. Renders a standalone result page. The acknowledgement is idempotent, so a re-click (or an email client prefetch) is harmless.""" try: data = _ack_serializer().loads(token, max_age=_ACK_MAX_AGE) except SignatureExpired: return render_template('scheduled_inspections/confirm_result.html', status='expired'), 400 except BadSignature: return render_template('scheduled_inspections/confirm_result.html', status='invalid'), 400 sid = data.get('sid') sched = db.session.get(ScheduledInspection, sid) if sid else None if sched is None: return render_template('scheduled_inspections/confirm_result.html', status='missing'), 404 # The token's inspector must still be the assigned inspector — a schedule # reassigned to someone else invalidates the previous assignee's link. if not sched.inspector_id or sched.inspector_id != data.get('iid'): return render_template('scheduled_inspections/confirm_result.html', status='reassigned', sched=sched), 409 if not sched.active: return render_template('scheduled_inspections/confirm_result.html', status='inactive', sched=sched) newly = _do_acknowledge( sched, sched.inspector.username if sched.inspector else 'inspector') return render_template('scheduled_inspections/confirm_result.html', status='confirmed' if newly else 'already', sched=sched) # ── Cron: reminders (advance / due / overdue) ───────────────────────────────── @bp.route('/run', methods=['POST']) def run_reminders(): token = request.form.get('token') or request.args.get('token') if not token or token != current_app.config.get('DIGEST_SECRET'): abort(403) today = now_eastern().date() sent = {'advance': 0, 'due': 0, 'overdue': 0, 'expired': 0} schedules = ScheduledInspection.query.filter_by(active=True).all() # Expire schedules past their end date BEFORE any reminder work (phase44). # fulfill() closes out a schedule that reaches its boundary by being # completed; this covers the one that reaches it without ever being done — # otherwise it stays active and re-alerts as overdue indefinitely. live = [] for s in schedules: if s.expire_if_past_end_date(today): sent['expired'] += 1 logger.info('SCHED INSP | expired | id=%s | end=%s', s.id, s.end_date) else: live.append(s) schedules = live # Cache admin/director recipients for overdue alerts managers = User.query.filter( User.role.in_(['admin', 'director']), User.active == True # noqa: E712 ).all() for s in schedules: inspector = s.inspector link = url_for('scheduled_inspections.index') fac_name = s.facility.name if s.facility else '—' tpl_name = s.template.name if s.template else '—' # Advance reminder — 1 day before due if (not s.advance_notified and inspector and inspector.active and s.next_due_date == today + timedelta(days=1)): notify( recipient = inspector, title = f'Inspection due tomorrow — {fac_name}', body = (f'Reminder: a "{tpl_name}" inspection at {fac_name} ' f'is scheduled for tomorrow ({s.next_due_date:%b %d, %Y}).'), link = link, event_type = EVENT_SCHEDULED_INSPECTION, send_email = True, extra_action = _confirm_action(s), ) s.advance_notified = True sent['advance'] += 1 # Due reminder — on/after due date if (not s.due_notified and inspector and inspector.active and s.next_due_date <= today): notify( recipient = inspector, title = f'Inspection due today — {fac_name}', body = (f'A "{tpl_name}" inspection at {fac_name} is due ' f'({s.next_due_date:%b %d, %Y}). Please complete it.'), link = link, event_type = EVENT_SCHEDULED_INSPECTION, send_email = True, extra_action = _confirm_action(s), ) s.due_notified = True sent['due'] += 1 # Overdue alert — due date has passed, still not fulfilled if not s.overdue_notified and s.next_due_date < today: for m in managers: notify( recipient = 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'{s.next_due_date:%b %d, %Y} and has not been completed.'), link = link, event_type = EVENT_SCHEDULED_INSPECTION, send_email = True, ) s.overdue_notified = True sent['overdue'] += 1 db.session.commit() logger.info('SCHED INSP | reminders | advance=%s due=%s overdue=%s expired=%s', sent['advance'], sent['due'], sent['overdue'], sent['expired']) return {'ok': True, 'sent': sent}, 200