Aug 5 - Update code to follow up ST - MT14b
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user