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
+4
View File
@@ -79,6 +79,10 @@ def _scheduled_payload(s):
# completed inspection. The iPad uses it to badge the row and to open
# the parent from the schedule detail.
'parent_inspection_id': s.parent_inspection_id,
# phase50 — receipt acknowledgement, per assignment rather than per
# occurrence. Lets the iPad badge unconfirmed assignments.
'is_acknowledged': s.is_acknowledged,
'acknowledged_at': s.acknowledged_at.isoformat() if s.acknowledged_at else None,
'notes': s.notes or None,
}
+20
View File
@@ -171,6 +171,21 @@ class InspectionSchedule(db.Model):
nullable=True, index=True,
)
# ── Receipt acknowledgement (phase50) ────────────────────────────────────
# Stamped when the assigned inspector confirms they received the request.
# NULL = awaiting confirmation.
#
# Per ASSIGNMENT, not per occurrence: fulfill() and advance_due_date()
# deliberately leave this alone as the schedule rolls forward, so an
# inspector who confirmed "yes, this weekly round is mine" is not asked
# again every week. The edit route resets it to NULL on reassignment to a
# DIFFERENT inspector, who has confirmed nothing.
#
# The acknowledger is always `inspector` — the only person the routes let
# confirm — so no separate acknowledged_by column is needed. Only meaningful
# for mode='plan'; an auto schedule has no request to receive.
acknowledged_at = db.Column(db.DateTime, nullable=True)
created_by = db.Column(
db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'),
nullable=True
@@ -228,6 +243,11 @@ class InspectionSchedule(db.Model):
"""True when this schedule was created to follow up an inspection."""
return self.parent_inspection_id is not None
@property
def is_acknowledged(self):
"""True once the assigned inspector has confirmed receipt (phase50)."""
return self.acknowledged_at is not None
FREQUENCY_LABELS = {
'once': 'One-time',
'daily': 'Daily',
+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
@@ -0,0 +1,116 @@
{# ── "Confirm receipt" result page (phase50) ─────────────────────────────────
Landing page for the one-click link in a scheduled-inspection assignment or
reminder email. Standalone — NOT extending base.html — because the viewer is
typically not logged in and base.html's nav assumes current_user. Same shape
as the public QR scan pages (facility_qr/area.html).
`status` is always set; `schedule` only for the statuses that found one.
confirmed — newly acknowledged (the happy path)
already — acknowledged before; a re-click or an email prefetch
reassigned — the token's inspector is no longer the assignee
inactive — schedule paused or ended since the email went out
expired — token older than 30 days
invalid — bad signature / mangled link
missing — schedule deleted since the email went out
#}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>Confirm Receipt — Scheduled Inspection</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<style>
body { background:#f1f5f9; color:#1f2937; }
.cf-wrap { max-width:520px; margin:3rem auto; padding:0 1rem; }
.cf-card { background:#fff; border-radius:.75rem; padding:2rem 1.5rem; text-align:center;
box-shadow:0 1px 3px rgba(0,0,0,.08); }
.cf-icon { font-size:3.5rem; line-height:1; }
.cf-meta { background:#f8fafc; border-radius:.5rem; padding:.9rem 1rem; text-align:left;
font-size:.9rem; margin-top:1.25rem; }
.cf-meta .lbl { color:#64748b; font-size:.75rem; text-transform:uppercase;
letter-spacing:.03em; }
</style>
</head>
<body>
<div class="cf-wrap">
<div class="cf-card">
{% if status == 'confirmed' %}
<div class="cf-icon text-success"><i class="bi bi-check-circle-fill"></i></div>
<h1 class="h4 mt-3 mb-2">Receipt confirmed</h1>
<p class="text-muted mb-0">
Thanks — we've let the scheduler know you've seen this request.
Nothing else to do right now.
</p>
{% elif status == 'already' %}
<div class="cf-icon text-success"><i class="bi bi-check-circle"></i></div>
<h1 class="h4 mt-3 mb-2">Already confirmed</h1>
<p class="text-muted mb-0">
You confirmed this one earlier. No need to do anything else.
</p>
{% elif status == 'reassigned' %}
<div class="cf-icon text-warning"><i class="bi bi-person-x"></i></div>
<h1 class="h4 mt-3 mb-2">No longer assigned to you</h1>
<p class="text-muted mb-0">
This scheduled inspection has been reassigned to someone else since the
email was sent, so there's nothing for you to confirm.
</p>
{% elif status == 'inactive' %}
<div class="cf-icon text-secondary"><i class="bi bi-pause-circle"></i></div>
<h1 class="h4 mt-3 mb-2">This schedule is no longer active</h1>
<p class="text-muted mb-0">
It has been paused or has reached its end date. No action is needed.
</p>
{% elif status == 'expired' %}
<div class="cf-icon text-secondary"><i class="bi bi-hourglass-bottom"></i></div>
<h1 class="h4 mt-3 mb-2">This link has expired</h1>
<p class="text-muted mb-0">
Confirmation links are good for 30 days. Sign in to the schedules page to
confirm, or ask your supervisor to resend the assignment.
</p>
{% elif status == 'missing' %}
<div class="cf-icon text-secondary"><i class="bi bi-question-circle"></i></div>
<h1 class="h4 mt-3 mb-2">Schedule not found</h1>
<p class="text-muted mb-0">
This scheduled inspection has since been removed. No action is needed.
</p>
{% else %}
<div class="cf-icon text-danger"><i class="bi bi-x-circle"></i></div>
<h1 class="h4 mt-3 mb-2">This link isn't valid</h1>
<p class="text-muted mb-0">
The link may have been copied incompletely. Try tapping it directly from
the email, or sign in to confirm from the schedules page.
</p>
{% endif %}
{% if schedule %}
<div class="cf-meta">
<div class="lbl">Inspection</div>
<div class="mb-2">
{{ schedule.template.name if schedule.template else '—' }}
at {{ schedule.facility.name if schedule.facility else '—' }}
</div>
<div class="lbl">Schedule</div>
<div>
{{ schedule.recurrence_label }}{% if schedule.due_date %} · due
{{ schedule.due_date.strftime('%b %d, %Y') }}{% endif %}
</div>
</div>
{% endif %}
</div>
<p class="text-center text-muted small mt-3 mb-0">
Janitorial QC System — automated message. Do not reply to the email.
</p>
</div>
</body>
</html>
@@ -84,8 +84,37 @@
{% if s.active %}<span class="badge bg-success">Active</span>
{% elif s.is_expired %}<span class="badge bg-dark">Ended</span>
{% else %}<span class="badge bg-secondary">Paused</span>{% endif %}
{# phase50 — receipt acknowledgement. Only meaningful for plan
mode: an auto schedule materialises itself, so there is no
request for anyone to receive. #}
{% if s.mode == 'plan' and s.inspector_id %}
{% if s.is_acknowledged %}
<span class="badge bg-light text-success border border-success ms-1"
title="Inspector confirmed receipt on {{ s.acknowledged_at.strftime('%b %d, %Y %I:%M %p') }}">
<i class="bi bi-check-circle"></i> Confirmed
</span>
{% elif s.active %}
<span class="badge bg-light text-warning border border-warning ms-1"
title="The assigned inspector has not confirmed receipt yet">
<i class="bi bi-hourglass-split"></i> Awaiting confirmation
</span>
{% endif %}
{% endif %}
</td>
<td class="text-end">
{# Assignee-only, like Start: a manager must not be able to confirm
on someone's behalf, since the record means "this person saw it". #}
{% if s.active and s.mode == 'plan' and not s.is_acknowledged
and s.inspector_id == current_user.id %}
<form method="POST" class="d-inline"
action="{{ url_for('inspection_schedules.acknowledge', schedule_id=s.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-success"
title="Confirm you have received this request">
<i class="bi bi-check-lg"></i> Confirm
</button>
</form>
{% endif %}
{% if s.active and s.mode == 'plan'
and (current_user.role != 'inspector' or s.inspector_id == current_user.id) %}
<a href="{{ url_for('inspection_schedules.start', schedule_id=s.id) }}"
+29 -3
View File
@@ -44,13 +44,26 @@ _EMAIL_HTML_SINGLE = """\
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;">
<h2 style="color:#0d6efd;">{{ title }}</h2>
<p>{{ body }}</p>
{% if link %}
{% if link or extra_action %}
<p>
{# extra_action (phase50) renders BEFORE "View Details" and in green: it is
the one-click action the email is asking for (e.g. "Confirm receipt"),
so it must be the primary button, not a footnote after the generic link. #}
{% if extra_action %}
<a href="{{ extra_action.url }}"
style="background:#198754;color:#fff;padding:10px 20px;
text-decoration:none;border-radius:4px;display:inline-block;
margin-right:8px;">
{{ extra_action.label }}
</a>
{% endif %}
{% if link %}
<a href="{{ base_url }}{{ link }}"
style="background:#0d6efd;color:#fff;padding:10px 20px;
text-decoration:none;border-radius:4px;display:inline-block;">
View Details
</a>
{% endif %}
</p>
{% endif %}
<hr style="border:none;border-top:1px solid #eee;margin-top:32px;">
@@ -68,6 +81,9 @@ _EMAIL_TEXT_SINGLE = """\
{{ title }}
{{ body }}
{% if extra_action %}
{{ extra_action.label }}: {{ extra_action.url }}
{% endif %}
{% if link %}
View: {{ base_url }}{{ link }}
{% endif %}
@@ -172,6 +188,7 @@ def notify(
event_type: str = None,
send_email: bool = True,
respect_preferences: bool = True,
extra_action: dict = None,
):
"""Create an in-app Notification record and optionally send an email.
@@ -189,6 +206,13 @@ def notify(
respect_preferences : When True (default), per-user email preferences gate delivery.
Set False for matrix-routed broadcasts the matrix is the
authority; individual opt-out should not override admin config.
extra_action : Optional dict {'label': str, 'url': str} rendered as a second,
primary button in the email (phase50). The URL must be
ABSOLUTE unlike `link`, it is not prefixed with base_url,
because it typically points at a login-free tokenised route
built with url_for(..., _external=True). EMAIL ONLY: the
in-app Notification row is unchanged, so a recipient reading
it in the bell menu simply follows `link` as before.
"""
# Determine digest flag before creating the record.
# Digest mode is only respected when individual preferences are in effect.
@@ -246,10 +270,10 @@ def notify(
elif should_send:
logger.info('EMAIL SEND | user=%s | event=%s | to=%s',
recipient.username, event_type, recipient.email)
_send_single_email(recipient, title, body, link)
_send_single_email(recipient, title, body, link, extra_action)
def _send_single_email(recipient, title, body, link):
def _send_single_email(recipient, title, body, link, extra_action=None):
"""Dispatch a single immediate notification email in a background thread.
Sending is offloaded to a daemon thread so SMTP latency never blocks the
@@ -265,9 +289,11 @@ def _send_single_email(recipient, title, body, link):
)
html_body = render_template_string(
_EMAIL_HTML_SINGLE, title=title, body=body, link=link, base_url=base_url,
extra_action=extra_action,
)
text_body = render_template_string(
_EMAIL_TEXT_SINGLE, title=title, body=body, link=link, base_url=base_url,
extra_action=extra_action,
)
msg = Message(
subject = f'[JQC] {title}',