Aug 3 - Add scheduled task confirm button in notification email, and separate pending/completed scheduled tasks UI

This commit is contained in:
2026-08-03 13:40:11 -04:00
parent 38acb6a52e
commit 991f242d12
6 changed files with 282 additions and 50 deletions
+146 -40
View File
@@ -20,6 +20,7 @@ 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 itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from app import db
from app.models.scheduled_inspection import (ScheduledInspection,
@@ -40,6 +41,40 @@ logger = logging.getLogger(__name__)
bp = Blueprint('scheduled_inspections', __name__, url_prefix='/scheduled-inspections')
# ── Email "Confirm receipt" one-click token (phase47) ─────────────────────────
# A signed, stateless token (no DB column) lets the assigned inspector confirm
# receipt straight from the assignment email, even when not logged in — the same
# login-free pattern the public QR pages use. The token binds the schedule id to
# the inspector id, so a schedule reassigned to someone else invalidates any
# link that was emailed to the previous assignee.
_ACK_SALT = 'scheduled-inspection-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(sched):
"""Signed token embedding the schedule id + the assigned inspector id."""
return _ack_serializer().dumps({'sid': sched.id, 'iid': sched.inspector_id})
def _confirm_action(sched):
"""`extra_action` dict for the email "Confirm receipt" button, or None when
there is nothing to confirm (no inspector, or already acknowledged). Requires
a request context for the external URL. Reused by the assignment email and
the advance/due reminder emails so an unconfirmed inspector can always
confirm from whichever email reaches them."""
if not sched.inspector_id or sched.is_acknowledged:
return None
return {
'label': 'Confirm receipt',
'url': url_for('scheduled_inspections.confirm_email',
token=_make_ack_token(sched), _external=True),
}
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
@@ -69,14 +104,16 @@ def _notify_assignee(sched, reassigned=False):
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,
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}. '
f'Please confirm you received this request.'),
link = url_for('scheduled_inspections.index'),
event_type = EVENT_SCHEDULED_INSPECTION,
send_email = True,
extra_action = _confirm_action(sched),
)
@@ -186,20 +223,38 @@ def index():
if current_user.role == 'customer':
abort(403)
# Two tabs (phase47): Pending = schedules still producing occurrences
# (active); Completed = closed schedules (fulfilled one-times, ended
# recurring, or manually deactivated). The partition is exhaustive and
# non-overlapping, so every schedule appears in exactly one tab; the in-row
# Status badge (Active / Ended / Inactive) disambiguates the closed ones.
tab = request.args.get('tab', 'pending')
if tab not in ('pending', 'completed'):
tab = 'pending'
today = now_eastern().date()
q = ScheduledInspection.query
base = 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)
base = base.filter(ScheduledInspection.inspector_id == current_user.id)
schedules = q.order_by(
ScheduledInspection.active.desc(),
ScheduledInspection.next_due_date.asc(),
).all()
pending_count = base.filter(ScheduledInspection.active.is_(True)).count()
completed_count = base.filter(ScheduledInspection.active.is_(False)).count()
if tab == 'pending':
schedules = (base.filter(ScheduledInspection.active.is_(True))
.order_by(ScheduledInspection.next_due_date.asc()).all())
else:
# Most recently completed first; NULL last_completed (e.g. manually
# switched off before ever running) sorts last under MySQL DESC.
schedules = (base.filter(ScheduledInspection.active.is_(False))
.order_by(ScheduledInspection.last_completed_at.desc(),
ScheduledInspection.next_due_date.desc()).all())
return render_template('scheduled_inspections/list.html',
schedules=schedules, today=today,
schedules=schedules, today=today, tab=tab,
pending_count=pending_count, completed_count=completed_count,
open_inspections=_open_inspection_ids(schedules))
@@ -416,23 +471,72 @@ def acknowledge(schedule_id):
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()
if _do_acknowledge(sched, 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('scheduled_inspections.index'))
def _do_acknowledge(sched, actor_username):
"""Stamp acknowledged_at, log the action, and notify the creator. 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.
The caller must have verified the actor is the assigned inspector."""
if sched.acknowledged_at is not None:
return False
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, actor_username)
_notify_creator_acknowledged(sched)
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 (phase47).
Login-free: authorised by the signed token, which binds the schedule id to
the inspector id it was emailed to. Renders a standalone result page. The
acknowledgement is idempotent, so a re-click (or an email client prefetch)
is harmless."""
try:
data = _ack_serializer().loads(token, max_age=_ACK_MAX_AGE)
except SignatureExpired:
return render_template('scheduled_inspections/confirm_result.html',
status='expired'), 400
except BadSignature:
return render_template('scheduled_inspections/confirm_result.html',
status='invalid'), 400
sid = data.get('sid')
sched = db.session.get(ScheduledInspection, sid) if sid else None
if sched is None:
return render_template('scheduled_inspections/confirm_result.html',
status='missing'), 404
# The token's inspector must still be the assigned inspector — a schedule
# reassigned to someone else invalidates the previous assignee's link.
if not sched.inspector_id or sched.inspector_id != data.get('iid'):
return render_template('scheduled_inspections/confirm_result.html',
status='reassigned', sched=sched), 409
if not sched.active:
return render_template('scheduled_inspections/confirm_result.html',
status='inactive', sched=sched)
newly = _do_acknowledge(
sched, sched.inspector.username if sched.inspector else 'inspector')
return render_template('scheduled_inspections/confirm_result.html',
status='confirmed' if newly else 'already', sched=sched)
# ── Cron: reminders (advance / due / overdue) ─────────────────────────────────
@bp.route('/run', methods=['POST'])
@@ -474,13 +578,14 @@ def run_reminders():
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,
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,
extra_action = _confirm_action(s),
)
s.advance_notified = True
sent['advance'] += 1
@@ -489,13 +594,14 @@ def run_reminders():
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,
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,
extra_action = _confirm_action(s),
)
s.due_notified = True
sent['due'] += 1