Aug 5 - Update code to follow up ST - MT14b

This commit is contained in:
2026-08-05 09:43:00 -04:00
parent 45ad924df8
commit be5484e1fb
9 changed files with 965 additions and 38 deletions
+189 -14
View File
@@ -30,6 +30,7 @@ Cron example
import logging
from datetime import datetime, timedelta
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from flask import (Blueprint, render_template, redirect, url_for, flash,
request, jsonify, current_app, abort)
from flask_login import login_required, current_user
@@ -192,6 +193,72 @@ def _active_inspectors():
).order_by(User.full_name, User.username).all()
# ── Email "Confirm receipt" one-click token (phase50) ─────────────────────────
# A signed, STATELESS token — no DB column, nothing to clean up — lets the
# assigned inspector confirm receipt straight from the assignment email without
# logging in, the same login-free pattern the public QR scan pages use.
#
# The token binds the schedule id to the inspector id, so reassigning a schedule
# to someone else silently invalidates any link already emailed to the previous
# assignee. That check happens at redemption, not issuance, which is what makes
# a stateless token safe here.
_ACK_SALT = 'inspection-schedule-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(schedule):
"""Signed token embedding the schedule id + the assigned inspector id."""
return _ack_serializer().dumps({'sid': schedule.id, 'iid': schedule.inspector_id})
def _confirm_action(schedule):
"""`extra_action` dict for the email "Confirm receipt" button, or None.
None when there is nothing to confirm — no inspector, already acknowledged,
or an auto-mode schedule (which materialises its own inspection, so there is
no request to receive). Requires a request context for the external URL.
Reused by the assignment email AND the advance/due reminders, so an inspector
who missed the first email can still confirm from whichever one reaches them.
"""
if not schedule.inspector_id or schedule.is_acknowledged or schedule.mode != 'plan':
return None
return {
'label': 'Confirm receipt',
'url': url_for('inspection_schedules.confirm_email',
token=_make_ack_token(schedule), _external=True),
}
def _notify_creator_acknowledged(schedule):
"""Tell the schedule's creator that the inspector confirmed receipt.
No-op when there is no creator, the creator is inactive, or the creator IS
the inspector (self-assigned — they do not need telling). Caller commits.
"""
creator = schedule.creator
if not creator or not creator.active or creator.id == schedule.inspector_id:
return
fac = schedule.facility.name if schedule.facility else ''
tpl = schedule.template.name if schedule.template else ''
who = schedule.inspector.display_name if schedule.inspector else 'The inspector'
due = schedule.next_run_at.strftime('%b %d, %Y') if schedule.next_run_at else 'soon'
notify(
creator,
title = f'Inspector confirmed receipt — {fac}',
body = (f'{who} confirmed receipt of the "{tpl}" scheduled '
f'inspection at {fac} ({schedule.recurrence_label.lower()}), '
f'due {due}.'),
link = url_for('inspection_schedules.index'),
event_type = EVENT_INSPECTION_SCHEDULED,
send_email = True,
)
def _notify_assignee(schedule: InspectionSchedule, reassigned: bool = False):
"""Tell the assigned inspector a schedule was assigned (or reassigned) to them.
@@ -208,10 +275,13 @@ def _notify_assignee(schedule: InspectionSchedule, reassigned: bool = False):
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,
f'({schedule.recurrence_label.lower()}), due {due}.'
+ (' Please confirm you received this request.'
if _confirm_action(schedule) else '')),
link = url_for('inspection_schedules.index'),
event_type = EVENT_INSPECTION_SCHEDULED,
send_email = True,
extra_action = _confirm_action(schedule),
)
@@ -420,6 +490,12 @@ def edit(schedule_id):
if facility_id and db.session.get(Facility, facility_id):
schedule.facility_id = facility_id
if inspector_id and db.session.get(User, inspector_id):
# Reassigning to a DIFFERENT inspector invalidates any prior
# confirmation — the new assignee has acknowledged nothing (phase50).
# Comparison before assignment, and only on a real change, so an
# unrelated save does not silently re-open a confirmed assignment.
if inspector_id != schedule.inspector_id:
schedule.acknowledged_at = None
schedule.inspector_id = inspector_id
schedule.area_id = request.form.get('area_id', type=int) or None
mode = request.form.get('mode', schedule.mode)
@@ -588,6 +664,100 @@ def start(schedule_id):
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 received the scheduled request.
Assignee-only, exactly like Start: a manager cannot confirm on someone's
behalf, because the whole point of the record is that THIS person saw it.
Idempotent — confirming twice is a no-op. On the first confirmation the
schedule's creator is notified.
"""
schedule = db.session.get(InspectionSchedule, schedule_id)
if schedule is None:
abort(404)
if not schedule.inspector_id or schedule.inspector_id != current_user.id:
abort(403)
if _do_acknowledge(schedule, 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('inspection_schedules.index'))
def _do_acknowledge(schedule, actor_username):
"""Stamp acknowledged_at, log, and notify the creator. Commits.
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, so the two can never drift apart. The caller must
have already verified the actor is the assigned inspector.
"""
if schedule.acknowledged_at is not None:
return False
schedule.acknowledged_at = now_eastern()
db.session.commit()
log_action(ACTION_UPDATE, 'InspectionSchedule', schedule.id, schedule.name,
'inspector confirmed receipt')
logger.info('INSPECTION SCHEDULE ACKNOWLEDGED | schedule=%s | by=%s',
schedule.id, actor_username)
_notify_creator_acknowledged(schedule)
db.session.commit()
return True
# ── Confirm receipt from the assignment email (login-free, token-signed) ──────
@bp.route('/confirm/<token>')
def confirm_email(token):
"""One-click "Confirm receipt" landing from the assignment email (phase50).
Deliberately login-free and NOT @login_required: authorisation comes from the
signed token, which binds the schedule id to the inspector id it was issued
for. Inspectors read this on a phone that is usually not logged in, and a
login wall is exactly what stops them confirming.
Every failure mode renders the same standalone page with a different status
rather than a bare 4xx, because the audience is a non-technical user who
clicked a link in an email. GET is safe to repeat: the acknowledgement is
idempotent, so a re-click or an email client's link prefetch is harmless.
"""
try:
data = _ack_serializer().loads(token, max_age=_ACK_MAX_AGE)
except SignatureExpired:
return render_template('inspection_schedules/confirm_result.html',
status='expired'), 400
except BadSignature:
return render_template('inspection_schedules/confirm_result.html',
status='invalid'), 400
sid = data.get('sid')
schedule = db.session.get(InspectionSchedule, sid) if sid else None
if schedule is None:
return render_template('inspection_schedules/confirm_result.html',
status='missing'), 404
# The token's inspector must STILL be the assigned inspector. This is what
# makes a stateless token safe: reassignment invalidates the old link at
# redemption without needing to track issued tokens anywhere.
if not schedule.inspector_id or schedule.inspector_id != data.get('iid'):
return render_template('inspection_schedules/confirm_result.html',
status='reassigned', schedule=schedule), 409
if not schedule.active:
return render_template('inspection_schedules/confirm_result.html',
status='inactive', schedule=schedule)
newly = _do_acknowledge(
schedule,
schedule.inspector.username if schedule.inspector else 'inspector')
return render_template('inspection_schedules/confirm_result.html',
status='confirmed' if newly else 'already',
schedule=schedule)
# ── Cron endpoint ─────────────────────────────────────────────────────────────
@bp.route('/run', methods=['POST'])
@@ -684,11 +854,15 @@ def _dispatch_reminders(plans, now):
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,
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,
# phase50 — still unconfirmed? Offer the button again here, so an
# inspector who missed the assignment email can confirm from
# whichever reminder reaches them. Returns None once confirmed.
extra_action = _confirm_action(s),
)
s.advance_notified = True
sent['advance'] += 1
@@ -699,11 +873,12 @@ def _dispatch_reminders(plans, now):
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,
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,
extra_action = _confirm_action(s), # phase50 — see above
)
s.due_notified = True
sent['due'] += 1