523 lines
23 KiB
Python
523 lines
23 KiB
Python
"""
|
|
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 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')
|
|
|
|
|
|
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_by(role='inspector', 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}.'),
|
|
link = url_for('scheduled_inspections.index'),
|
|
event_type = EVENT_SCHEDULED_INSPECTION,
|
|
send_email = True,
|
|
)
|
|
|
|
|
|
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 _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)
|
|
|
|
today = now_eastern().date()
|
|
q = ScheduledInspection.query
|
|
|
|
# Inspectors see only their own assignments; managers see everything.
|
|
if current_user.role == 'inspector':
|
|
q = q.filter(ScheduledInspection.inspector_id == current_user.id)
|
|
|
|
schedules = q.order_by(
|
|
ScheduledInspection.active.desc(),
|
|
ScheduledInspection.next_due_date.asc(),
|
|
).all()
|
|
|
|
return render_template('scheduled_inspections/list.html',
|
|
schedules=schedules, today=today,
|
|
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():
|
|
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('/<int:schedule_id>/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():
|
|
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('/<int:schedule_id>/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('/<int:schedule_id>/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('/<int:schedule_id>/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 sched.acknowledged_at is None:
|
|
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, current_user.username)
|
|
_notify_creator_acknowledged(sched)
|
|
db.session.commit()
|
|
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'))
|
|
|
|
|
|
# ── 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,
|
|
)
|
|
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,
|
|
)
|
|
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
|