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
@@ -0,0 +1,73 @@
<!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 — Janitorial QC</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<style>
body { background:#eef1f5; }
.wrap { max-width: 520px; margin: 8vh auto 0; padding: 16px; }
.card-soft { background:#fff; border:1px solid #e6e9ef; border-radius:.85rem; }
.badge-icon { width:64px; height:64px; border-radius:50%; display:flex; align-items:center;
justify-content:center; font-size:2rem; margin:0 auto 12px; }
</style>
</head>
<body>
<div class="wrap">
<div class="card-soft shadow-sm p-4 text-center">
{% set ok = status in ['confirmed', 'already'] %}
<div class="badge-icon"
style="background:{{ '#e6f7ec' if ok else '#fdecec' }};color:{{ '#198754' if ok else '#dc3545' }};">
<i class="bi bi-{{ 'check-circle-fill' if ok else 'exclamation-triangle-fill' }}"></i>
</div>
{% if status == 'confirmed' %}
<h4 class="mb-2">Receipt confirmed</h4>
<p class="text-muted mb-0">Thank you — you've confirmed you received this scheduled inspection request.</p>
{% elif status == 'already' %}
<h4 class="mb-2">Already confirmed</h4>
<p class="text-muted mb-0">You had already confirmed receipt of this scheduled inspection. No further action is needed.</p>
{% elif status == 'reassigned' %}
<h4 class="mb-2">This request was reassigned</h4>
<p class="text-muted mb-0">This scheduled inspection is no longer assigned to you, so it can't be confirmed from this link.</p>
{% elif status == 'inactive' %}
<h4 class="mb-2">No longer active</h4>
<p class="text-muted mb-0">This scheduled inspection is no longer active, so there's nothing to confirm.</p>
{% elif status == 'expired' %}
<h4 class="mb-2">Link expired</h4>
<p class="text-muted mb-0">This confirmation link has expired. Please sign in to confirm receipt from the Scheduled Inspections page.</p>
{% else %}
<h4 class="mb-2">Invalid link</h4>
<p class="text-muted mb-0">This confirmation link is not valid. Please sign in to confirm receipt from the Scheduled Inspections page.</p>
{% endif %}
{% if sched %}
<hr class="my-3">
<div class="text-start small">
<div class="mb-1"><span class="text-muted">Facility:</span>
<strong>{{ sched.facility.name if sched.facility else '—' }}</strong></div>
<div class="mb-1"><span class="text-muted">Template:</span>
{{ sched.template.name if sched.template else '—' }}</div>
<div><span class="text-muted">Due:</span>
{{ sched.next_due_date.strftime('%b %d, %Y') if sched.next_due_date else '—' }}
<span class="text-muted">·</span> {{ sched.recurrence_label }}</div>
</div>
{% endif %}
<div class="mt-4">
<a href="{{ url_for('scheduled_inspections.index') }}" class="btn btn-primary">
<i class="bi bi-box-arrow-in-right"></i> Open Scheduled Inspections
</a>
</div>
<p class="text-muted mt-3 mb-0" style="font-size:.75rem;">
You may be asked to sign in.
</p>
</div>
</div>
</body>
</html>
+31 -4
View File
@@ -19,7 +19,25 @@
</div>
</div>
<div class="card shadow-sm">
{# Pending / Completed tabs (phase47). #}
<ul class="nav nav-tabs mb-0">
<li class="nav-item">
<a class="nav-link {{ 'active' if tab == 'pending' }}"
href="{{ url_for('scheduled_inspections.index', tab='pending') }}">
<i class="bi bi-hourglass-split"></i> Pending
<span class="badge rounded-pill bg-{{ 'primary' if tab == 'pending' else 'secondary' }} ms-1">{{ pending_count }}</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if tab == 'completed' }}"
href="{{ url_for('scheduled_inspections.index', tab='completed') }}">
<i class="bi bi-check2-circle"></i> Completed
<span class="badge rounded-pill bg-{{ 'primary' if tab == 'completed' else 'secondary' }} ms-1">{{ completed_count }}</span>
</a>
</li>
</ul>
<div class="card shadow-sm border-top-0" style="border-top-left-radius:0;border-top-right-radius:0;">
<div class="card-body p-0">
{% if schedules %}
<div class="table-responsive">
@@ -74,6 +92,11 @@
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
{% if s.last_completed_at %}
<div class="small text-muted" title="Last completed">
<i class="bi bi-check2"></i> {{ s.last_completed_at.strftime('%b %d, %Y') }}
</div>
{% endif %}
</td>
<td>
{# Receipt confirmation by the assigned inspector (phase47). #}
@@ -88,8 +111,8 @@
<span class="badge bg-warning text-dark" title="The inspector has not confirmed receipt yet">
<i class="bi bi-hourglass-split"></i> Awaiting
</span>
{# The assignee can confirm right here. #}
{% if s.inspector_id == current_user.id %}
{# The assignee can confirm right here (only while still active). #}
{% if s.active and s.inspector_id == current_user.id %}
<form method="POST" class="d-inline"
action="{{ url_for('scheduled_inspections.acknowledge', schedule_id=s.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
@@ -138,10 +161,14 @@
</div>
{% else %}
<div class="p-4 text-muted text-center">
No scheduled inspections yet.
{% if tab == 'completed' %}
No completed scheduled inspections yet.
{% else %}
No pending scheduled inspections.
{% if current_user.role in ['admin','director','project_manager','auditor'] %}
<a href="{{ url_for('scheduled_inspections.create') }}">Create one</a>.
{% endif %}
{% endif %}
</div>
{% endif %}
</div>
+24 -3
View File
@@ -44,13 +44,23 @@ _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>
{% 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 +78,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 +185,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 +203,11 @@ 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
(green) button in the email, before "View Details". `url` must
be an absolute URL (it is NOT prefixed with base_url). Used for
the scheduled-inspection "Confirm receipt" email link. In-app
notifications are unaffected — this only shapes the email.
"""
# Determine digest flag before creating the record.
# Digest mode is only respected when individual preferences are in effect.
@@ -246,10 +265,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 +284,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}',