This commit is contained in:
2026-07-31 15:04:10 -04:00
23 changed files with 1461 additions and 70 deletions
+5
View File
@@ -361,8 +361,10 @@ def index():
# ── Scheduled inspections (phase36): upcoming / overdue ──────────────
sched_upcoming = []
sched_overdue_count = 0
sched_open_inspections = {}
if not is_customer:
from app.models.scheduled_inspection import ScheduledInspection
from app.routes.scheduled_inspections import _open_inspection_ids
_today = now.date()
_sq = ScheduledInspection.query.filter_by(active=True)
if is_inspector:
@@ -374,11 +376,14 @@ def index():
s for s in _all_sched
if _today <= s.next_due_date <= _today + timedelta(days=7)
][:8]
# Offer Continue (not a duplicate Start) where one is already underway.
sched_open_inspections = _open_inspection_ids(sched_upcoming)
return render_template(
'dashboard.html',
sched_upcoming = sched_upcoming,
sched_overdue_count = sched_overdue_count,
sched_open_inspections = sched_open_inspections,
submitted_this_week = submitted_this_week,
completed_today = completed_today,
open_issues = open_issues,
+71 -16
View File
@@ -21,6 +21,7 @@ from app.utils.notifications import notify, notify_customers_for_facility, notif
from app.models.notification import (
EVENT_INSPECTION_DONE, EVENT_ISSUE_ASSIGNED,
EVENT_CUSTOMER_INSPECTION_DONE, EVENT_CUSTOMER_ISSUE_UPDATED,
EVENT_FOLLOWUP_REQUESTED,
)
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT
from app.utils.scope import get_customer_scope, get_inspector_scope
@@ -1215,29 +1216,61 @@ def export_pdf(inspection_id):
@bp.route('/<int:inspection_id>/flag-followup', methods=['POST'])
@login_required
@supervisor_required
def flag_followup(inspection_id):
"""Mark an inspection as requiring a follow-up re-inspection."""
"""Mark an inspection as requiring a follow-up re-inspection.
Open to admin/director AND to customers for their own facilities — a client
unhappy with a result can ask for a re-inspection directly rather than
going through support. Every other role is refused.
Customers may only *request*: they cannot clear the flag (see
clear_followup, still admin/director) nor run the re-inspection itself.
"""
inspection = db.session.get(Inspection, inspection_id)
if inspection is None:
abort(404)
is_customer = current_user.role == 'customer'
if is_customer:
# Same facility scope as view() — a customer must not be able to reach
# another client's inspection with a crafted POST.
if inspection.facility_id not in (get_customer_scope(current_user) or []):
abort(403)
# Nothing to follow up on until the inspection has been submitted.
if inspection.status != 'completed':
flash('You can only request a follow-up on a completed inspection.', 'warning')
return redirect(url_for('inspections.view', inspection_id=inspection_id))
# Don't let a repeat request overwrite the note/attribution of a pending
# one — the flag is already raised and staff are already on it.
if inspection.follow_up_required:
flash('A follow-up has already been requested for this inspection.', 'info')
return redirect(url_for('inspections.view', inspection_id=inspection_id))
elif current_user.role not in ('admin', 'director'):
abort(403)
note = request.form.get('follow_up_note', '').strip() or None
inspection.follow_up_required = True
inspection.follow_up_note = note
inspection.follow_up_required = True
inspection.follow_up_note = note
inspection.follow_up_requested_by = current_user.id
inspection.follow_up_requested_at = now_eastern()
db.session.commit()
# Notify the original inspector so they see it on the iPad
note_suffix = f' Note: {note}' if note else ''
who = (f'The customer ({current_user.display_name})' if is_customer
else current_user.display_name)
body = (
f'{who} has requested a follow-up re-inspection '
f'of "{inspection.template.name}" at {inspection.facility.name}.{note_suffix}'
)
# Notify the original inspector so they see it on the iPad.
inspector = db.session.get(User, inspection.inspector_id)
if inspector and inspector.id != current_user.id:
note_suffix = f' Note: {note}' if note else ''
notify(
recipient = inspector,
title = f'Follow-Up Required: Inspection #{inspection_id}',
body = (
f'{current_user.username} has requested a follow-up re-inspection '
f'of "{inspection.template.name}" at {inspection.facility.name}.{note_suffix}'
),
body = body,
link = url_for('inspections.view', inspection_id=inspection_id),
inspection_id = inspection_id,
event_type = EVENT_INSPECTION_DONE,
@@ -1245,14 +1278,34 @@ def flag_followup(inspection_id):
)
db.session.commit()
# Route to the staff who action follow-ups. Going through notify_by_matrix
# rather than notifying managers directly keeps recipients admin-configurable
# and lets per-contract recipients fire too (rule 73). This matters most for
# a customer request: without it only the inspector would hear about it and
# nobody would be accountable for scheduling the re-inspection.
notify_by_matrix(
event_type = EVENT_FOLLOWUP_REQUESTED,
title = f'Follow-Up Requested: Inspection #{inspection_id}',
body = body,
link = url_for('inspections.view', inspection_id=inspection_id),
inspection_id = inspection_id,
facility_id = inspection.facility_id,
exclude_user_ids = {current_user.id,
inspector.id if inspector else None} - {None},
)
db.session.commit()
current_app.logger.info(
'INSPECTION FOLLOW-UP FLAGGED | id=%s | by=%s | note=%r',
inspection_id, current_user.username, note,
'INSPECTION FOLLOW-UP FLAGGED | id=%s | by=%s (%s) | note=%r',
inspection_id, current_user.username, current_user.role, note,
)
log_action(ACTION_UPDATE, 'Inspection', inspection_id,
f'{inspection.template.name} @ {inspection.facility.name}',
f'follow_up_required=True; note={note!r}')
flash('Follow-up inspection required flag set.', 'warning')
f'follow_up_required=True; by_role={current_user.role}; note={note!r}')
if is_customer:
flash('Follow-up re-inspection requested. The team has been notified.', 'success')
else:
flash('Follow-up inspection required flag set.', 'warning')
return redirect(url_for('inspections.view', inspection_id=inspection_id))
@@ -1264,8 +1317,10 @@ def clear_followup(inspection_id):
inspection = db.session.get(Inspection, inspection_id)
if inspection is None:
abort(404)
inspection.follow_up_required = False
inspection.follow_up_note = None
inspection.follow_up_required = False
inspection.follow_up_note = None
inspection.follow_up_requested_by = None
inspection.follow_up_requested_at = None
db.session.commit()
log_action(ACTION_UPDATE, 'Inspection', inspection_id,
f'{inspection.template.name} @ {inspection.facility.name}',
+135 -10
View File
@@ -22,7 +22,8 @@ from flask import (Blueprint, render_template, redirect, url_for, flash,
from flask_login import login_required, current_user
from app import db
from app.models.scheduled_inspection import ScheduledInspection
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
@@ -79,6 +80,72 @@ def _notify_assignee(sched, reassigned=False):
)
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."""
@@ -110,7 +177,8 @@ def index():
).all()
return render_template('scheduled_inspections/list.html',
schedules=schedules, today=today)
schedules=schedules, today=today,
open_inspections=_open_inspection_ids(schedules))
# ── Create ──────────────────────────────────────────────────────────────────
@@ -121,6 +189,10 @@ def index():
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()
@@ -129,17 +201,25 @@ def create():
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,
)
_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.frequency}; due={sched.next_due_date}; inspector={sched.inspector_id}')
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.
@@ -166,20 +246,36 @@ def edit(schedule_id):
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.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
_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.frequency}; due={sched.next_due_date}; active={sched.active}')
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:
@@ -242,6 +338,16 @@ def start(schedule_id):
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,
@@ -250,6 +356,12 @@ def start(schedule_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()
@@ -271,10 +383,23 @@ def run_reminders():
abort(403)
today = now_eastern().date()
sent = {'advance': 0, 'due': 0, 'overdue': 0}
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
@@ -333,6 +458,6 @@ def run_reminders():
sent['overdue'] += 1
db.session.commit()
logger.info('SCHED INSP | reminders | advance=%s due=%s overdue=%s',
sent['advance'], sent['due'], sent['overdue'])
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