Files
LT_Janitorial_Quality_Control/app/routes/scheduled_inspections.py
T

339 lines
14 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
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 _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)
# ── Create ──────────────────────────────────────────────────────────────────
@bp.route('/new', methods=['GET', 'POST'])
@login_required
@project_manager_required
def create():
form = ScheduledInspectionForm()
_populate_choices(form)
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,
frequency = form.frequency.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,
)
db.session.add(sched)
db.session.commit()
log_action(ACTION_CREATE, 'ScheduledInspection', sched.id,
f'{sched.template.name} @ {sched.facility.name}',
f'freq={sched.frequency}; due={sched.next_due_date}; 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)
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.frequency = form.frequency.data
sched.next_due_date = form.next_due_date.data
sched.notes = (form.notes.data or '').strip() or None
sched.active = form.active.data
db.session.commit()
log_action(ACTION_UPDATE, 'ScheduledInspection', sched.id,
f'{sched.template.name} @ {sched.facility.name}',
f'freq={sched.frequency}; due={sched.next_due_date}; 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'))
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,
)
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))
# ── 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}
schedules = ScheduledInspection.query.filter_by(active=True).all()
# 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',
sent['advance'], sent['due'], sent['overdue'])
return {'ok': True, 'sent': sent}, 200