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',
+176 -1
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}.'),
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'])
@@ -689,6 +859,10 @@ def _dispatch_reminders(plans, now):
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
@@ -704,6 +878,7 @@ def _dispatch_reminders(plans, now):
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}',
@@ -0,0 +1,83 @@
"""phase50 — inspection schedule receipt acknowledgement
Ports single-tenant phase47 onto MT's `inspection_schedules` table. Adds:
acknowledged_at DATETIME NULL
Stamped when the assigned inspector confirms they have received/seen a scheduled
inspection request. NULL = awaiting confirmation.
Scope of the acknowledgement
----------------------------
It is per ASSIGNMENT, not per occurrence. `fulfill()` and `advance_due_date()`
deliberately leave this column alone as the schedule rolls forward an
inspector who confirmed "yes, this weekly restroom round is mine" should not be
asked again every week. The edit route resets it to NULL when the schedule is
reassigned to a DIFFERENT inspector, because the new assignee has confirmed
nothing.
The acknowledger is always the assigned inspector the only person the routes
permit to confirm so no separate `acknowledged_by` column is stored.
Only meaningful for `plan` mode: an `auto` schedule materialises its inspection
without anyone starting it, so there is no request to receive. The UI shows the
control for plan-mode rows only.
No backfill: legacy rows keep NULL and render as "awaiting confirmation", which
is the correct initial state for an assignment nobody has confirmed yet.
Revision id note
----------------
`alembic_version.version_num` is VARCHAR(32); the id below is 24 characters.
The filename stays descriptive Alembic keys on the `revision` string.
Uses an INFORMATION_SCHEMA check safe to re-run on every tenant DB. Additive
only: nothing is renamed, retyped or dropped.
"""
revision = 'phase50_sched_acknowledged'
down_revision = 'phase49_followup_req_by'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
_TABLE = 'inspection_schedules'
_COLUMN = 'acknowledged_at'
def _table_exists(conn, table):
return conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t"
), {"t": table}).scalar() > 0
def _column_exists(conn, table, column):
return conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() "
"AND TABLE_NAME = :t AND COLUMN_NAME = :c"
), {"t": table, "c": column}).scalar() > 0
def upgrade():
bind = op.get_bind()
if not _table_exists(bind, _TABLE):
return
if not _column_exists(bind, _TABLE, _COLUMN):
op.execute(sa.text(
f"ALTER TABLE {_TABLE} ADD COLUMN {_COLUMN} DATETIME NULL"
))
def downgrade():
bind = op.get_bind()
if not _table_exists(bind, _TABLE):
return
if _column_exists(bind, _TABLE, _COLUMN):
op.execute(sa.text(
f"ALTER TABLE {_TABLE} DROP COLUMN {_COLUMN}"
))
+458
View File
@@ -0,0 +1,458 @@
"""
tests/test_schedule_acknowledge.py
-----------------------------------
Behaviour tests for phase50 scheduled inspection receipt acknowledgement.
Runs on the in-memory SQLite app fixture (multi-tenancy inert). Covers:
* the assigned inspector can confirm receipt; the stamp and creator
notification land, and a second confirm is an idempotent no-op
* a manager cannot confirm on the inspector's behalf (assignee-only)
* the login-free token route confirms without a session, and is safe to
re-request (email client prefetch)
* a token issued to a previous assignee stops working once the schedule is
reassigned the property that makes a stateless token safe
* tampered, expired and dangling tokens each render their own status page
rather than a bare error
* reassignment resets the acknowledgement; an unrelated edit does NOT
* acknowledgement is per ASSIGNMENT, not per occurrence rolling the schedule
forward must not re-open it
* notify(extra_action=...) renders the confirm button in the email, and stops
once confirmed
"""
from datetime import datetime, timedelta
import pytest
@pytest.fixture
def client(app):
"""Fresh schema + test client for each test (isolated in-memory DB)."""
app.config['DIGEST_SECRET'] = 'test-digest'
with app.app_context():
from app import db
from app.models import inspector_assignment # noqa: F401
db.drop_all()
db.create_all()
yield app.test_client()
db.session.remove()
def _today():
"""Today in the app's timezone, not the machine's."""
from app.utils.time_utils import now_eastern
return now_eastern().date()
def _user(username, role):
from app import db
from app.models.user import User
u = User(username=username, full_name=username.title(), role=role,
email=f'{username}@example.com', active=True)
u.set_password('pw-correct1')
db.session.add(u)
db.session.commit()
return u
def _seed():
from app import db
from app.models.facility import Facility
from app.models.inspection import InspectionTemplate
tmpl = InspectionTemplate(name='Restroom Check', active=True,
form_schema=[{'id': 'f1', 'type': 'rating_5',
'label': 'Clean', 'row': 0, 'col': 0,
'rowSpan': 1, 'colSpan': 1}])
fac = Facility(name='Main Office', active=True)
db.session.add_all([tmpl, fac])
db.session.commit()
return tmpl, fac
def _schedule(tmpl, fac, inspector, creator, **kw):
from app import db
from app.models.inspection_schedule import InspectionSchedule
from app.utils.time_utils import now_eastern
kw.setdefault('mode', 'plan')
kw.setdefault('frequency', 'weekly')
kw.setdefault('active', True)
s = InspectionSchedule(name='Weekly restrooms', template_id=tmpl.id,
facility_id=fac.id, inspector_id=inspector.id,
created_by=creator.id,
next_run_at=now_eastern() + timedelta(days=3), **kw)
db.session.add(s)
db.session.commit()
return s
def _login(client, user):
return client.post('/auth/login',
data={'username': user.username, 'password': 'pw-correct1'},
follow_redirects=True)
# ── Logged-in acknowledgement ────────────────────────────────────────────────
def test_assigned_inspector_can_confirm(client):
from app import db
from app.models.inspection_schedule import InspectionSchedule
from app.models.notification import Notification
tmpl, fac = _seed()
insp = _user('ivy', 'inspector')
mgr = _user('mona', 'admin')
s = _schedule(tmpl, fac, insp, mgr)
assert s.is_acknowledged is False
_login(client, insp)
resp = client.post(f'/inspection-schedules/{s.id}/acknowledge')
assert resp.status_code == 302
db.session.expire_all()
s = db.session.get(InspectionSchedule, s.id)
assert s.is_acknowledged is True
assert s.acknowledged_at is not None
# The creator is told, since they are the one waiting on the confirmation.
assert any('confirmed receipt' in n.title.lower()
for n in Notification.query.filter_by(user_id=mgr.id).all())
def test_confirming_twice_is_idempotent(client):
from app import db
from app.models.inspection_schedule import InspectionSchedule
from app.models.notification import Notification
tmpl, fac = _seed()
insp = _user('ivy', 'inspector')
mgr = _user('mona', 'admin')
s = _schedule(tmpl, fac, insp, mgr)
_login(client, insp)
client.post(f'/inspection-schedules/{s.id}/acknowledge')
db.session.expire_all()
first = db.session.get(InspectionSchedule, s.id).acknowledged_at
client.post(f'/inspection-schedules/{s.id}/acknowledge')
db.session.expire_all()
assert db.session.get(InspectionSchedule, s.id).acknowledged_at == first
# And the creator is not pestered a second time.
assert len([n for n in Notification.query.filter_by(user_id=mgr.id).all()
if 'confirmed receipt' in n.title.lower()]) == 1
def test_manager_cannot_confirm_on_the_inspectors_behalf(client):
"""The record means "this person saw it", so only the assignee may set it."""
from app import db
from app.models.inspection_schedule import InspectionSchedule
tmpl, fac = _seed()
insp = _user('ivy', 'inspector')
mgr = _user('mona', 'admin')
s = _schedule(tmpl, fac, insp, mgr)
_login(client, mgr)
assert client.post(f'/inspection-schedules/{s.id}/acknowledge').status_code == 403
db.session.expire_all()
assert db.session.get(InspectionSchedule, s.id).is_acknowledged is False
def test_other_inspector_cannot_confirm(client):
from app import db
from app.models.inspection_schedule import InspectionSchedule
tmpl, fac = _seed()
insp = _user('ivy', 'inspector')
other = _user('otto', 'inspector')
mgr = _user('mona', 'admin')
s = _schedule(tmpl, fac, insp, mgr)
_login(client, other)
assert client.post(f'/inspection-schedules/{s.id}/acknowledge').status_code == 403
db.session.expire_all()
assert db.session.get(InspectionSchedule, s.id).is_acknowledged is False
# ── Login-free email token ───────────────────────────────────────────────────
def _token_for(app, schedule):
from app.routes.inspection_schedules import _make_ack_token
with app.test_request_context():
return _make_ack_token(schedule)
def test_email_token_confirms_without_a_session(client, app):
"""Inspectors read this on a phone that is usually not logged in — a login
wall is exactly what stops them confirming."""
from app import db
from app.models.inspection_schedule import InspectionSchedule
tmpl, fac = _seed()
insp = _user('ivy', 'inspector')
mgr = _user('mona', 'admin')
s = _schedule(tmpl, fac, insp, mgr)
token = _token_for(app, s)
resp = client.get(f'/inspection-schedules/confirm/{token}')
assert resp.status_code == 200
assert b'Receipt confirmed' in resp.data
db.session.expire_all()
assert db.session.get(InspectionSchedule, s.id).is_acknowledged is True
def test_email_token_reclick_is_harmless(client, app):
"""A re-click, or an email client prefetching the link, must not error."""
tmpl, fac = _seed()
insp = _user('ivy', 'inspector')
mgr = _user('mona', 'admin')
s = _schedule(tmpl, fac, insp, mgr)
token = _token_for(app, s)
client.get(f'/inspection-schedules/confirm/{token}')
resp = client.get(f'/inspection-schedules/confirm/{token}')
assert resp.status_code == 200
assert b'Already confirmed' in resp.data
def test_reassignment_invalidates_a_previously_emailed_token(client, app):
"""This is what makes a stateless token safe: the old link dies at
redemption, with nothing tracked anywhere."""
from app import db
from app.models.inspection_schedule import InspectionSchedule
tmpl, fac = _seed()
insp = _user('ivy', 'inspector')
other = _user('otto', 'inspector')
mgr = _user('mona', 'admin')
s = _schedule(tmpl, fac, insp, mgr)
token = _token_for(app, s) # emailed to Ivy
s.inspector_id = other.id # reassigned to Otto
db.session.commit()
resp = client.get(f'/inspection-schedules/confirm/{token}')
assert resp.status_code == 409
assert b'No longer assigned to you' in resp.data
db.session.expire_all()
assert db.session.get(InspectionSchedule, s.id).is_acknowledged is False
def test_tampered_token_renders_the_invalid_page(client):
resp = client.get('/inspection-schedules/confirm/not-a-real-token')
assert resp.status_code == 400
assert b"isn't valid" in resp.data
def test_expired_token_renders_the_expired_page(client, app):
from itsdangerous import URLSafeTimedSerializer
tmpl, fac = _seed()
insp = _user('ivy', 'inspector')
mgr = _user('mona', 'admin')
s = _schedule(tmpl, fac, insp, mgr)
with app.test_request_context():
from app.routes.inspection_schedules import _ACK_SALT
ser = URLSafeTimedSerializer(app.config['SECRET_KEY'], salt=_ACK_SALT)
# Sign it 31 days ago — past the 30-day _ACK_MAX_AGE.
import itsdangerous.timed
old = ser.dumps({'sid': s.id, 'iid': insp.id})
# Re-sign with a backdated timestamp by monkeypatching the serializer clock.
import time as _time
real = _time.time
try:
_time.time = lambda: real() - (60 * 60 * 24 * 31)
with app.test_request_context():
old = URLSafeTimedSerializer(
app.config['SECRET_KEY'], salt=_ACK_SALT
).dumps({'sid': s.id, 'iid': insp.id})
finally:
_time.time = real
resp = client.get(f'/inspection-schedules/confirm/{old}')
assert resp.status_code == 400
assert b'expired' in resp.data.lower()
def test_token_for_a_deleted_schedule_renders_missing(client, app):
from app import db
from app.models.inspection_schedule import InspectionSchedule
tmpl, fac = _seed()
insp = _user('ivy', 'inspector')
mgr = _user('mona', 'admin')
s = _schedule(tmpl, fac, insp, mgr)
token = _token_for(app, s)
db.session.delete(db.session.get(InspectionSchedule, s.id))
db.session.commit()
resp = client.get(f'/inspection-schedules/confirm/{token}')
assert resp.status_code == 404
assert b'not found' in resp.data.lower()
def test_inactive_schedule_renders_inactive(client, app):
from app import db
tmpl, fac = _seed()
insp = _user('ivy', 'inspector')
mgr = _user('mona', 'admin')
s = _schedule(tmpl, fac, insp, mgr)
token = _token_for(app, s)
s.active = False
db.session.commit()
resp = client.get(f'/inspection-schedules/confirm/{token}')
assert resp.status_code == 200
assert b'no longer active' in resp.data.lower()
# ── Reset semantics ──────────────────────────────────────────────────────────
def test_reassignment_resets_the_acknowledgement(client):
from app import db
from app.models.inspection_schedule import InspectionSchedule
tmpl, fac = _seed()
insp = _user('ivy', 'inspector')
other = _user('otto', 'inspector')
mgr = _user('mona', 'admin')
s = _schedule(tmpl, fac, insp, mgr)
_login(client, insp)
client.post(f'/inspection-schedules/{s.id}/acknowledge')
client.get('/auth/logout', follow_redirects=True)
_login(client, mgr)
client.post(f'/inspection-schedules/{s.id}/edit', data={
'name': 'Weekly restrooms', 'template_id': tmpl.id, 'facility_id': fac.id,
'inspector_id': other.id, 'frequency': 'weekly', 'mode': 'plan',
'weekdays': ['0'], 'active': 'on',
}, follow_redirects=True)
db.session.expire_all()
s = db.session.get(InspectionSchedule, s.id)
assert s.inspector_id == other.id
assert s.is_acknowledged is False
def test_unrelated_edit_does_not_reset_the_acknowledgement(client):
"""Only a real reassignment re-opens it — otherwise every rename would make
the inspector confirm again."""
from app import db
from app.models.inspection_schedule import InspectionSchedule
tmpl, fac = _seed()
insp = _user('ivy', 'inspector')
mgr = _user('mona', 'admin')
s = _schedule(tmpl, fac, insp, mgr)
_login(client, insp)
client.post(f'/inspection-schedules/{s.id}/acknowledge')
client.get('/auth/logout', follow_redirects=True)
db.session.expire_all()
stamped = db.session.get(InspectionSchedule, s.id).acknowledged_at
_login(client, mgr)
client.post(f'/inspection-schedules/{s.id}/edit', data={
'name': 'Renamed round', 'template_id': tmpl.id, 'facility_id': fac.id,
'inspector_id': insp.id, 'frequency': 'weekly', 'mode': 'plan',
'weekdays': ['0'], 'active': 'on',
}, follow_redirects=True)
db.session.expire_all()
s = db.session.get(InspectionSchedule, s.id)
assert s.name == 'Renamed round'
assert s.acknowledged_at == stamped
def test_acknowledgement_survives_rolling_the_schedule_forward(client):
"""Per ASSIGNMENT, not per occurrence — an inspector who confirmed "this
weekly round is mine" must not be asked again every week."""
from app import db
from app.models.inspection_schedule import InspectionSchedule
tmpl, fac = _seed()
insp = _user('ivy', 'inspector')
mgr = _user('mona', 'admin')
s = _schedule(tmpl, fac, insp, mgr, frequency='daily')
_login(client, insp)
client.post(f'/inspection-schedules/{s.id}/acknowledge')
db.session.expire_all()
s = db.session.get(InspectionSchedule, s.id)
stamped = s.acknowledged_at
s.fulfill()
db.session.commit()
assert s.acknowledged_at == stamped
assert s.is_acknowledged is True
# ── notify(extra_action=...) ─────────────────────────────────────────────────
def test_confirm_action_is_offered_only_when_there_is_something_to_confirm(client, app):
from app import db
from app.routes.inspection_schedules import _confirm_action
tmpl, fac = _seed()
insp = _user('ivy', 'inspector')
mgr = _user('mona', 'admin')
s = _schedule(tmpl, fac, insp, mgr)
with app.test_request_context():
action = _confirm_action(s)
assert action is not None
assert action['label'] == 'Confirm receipt'
assert '/inspection-schedules/confirm/' in action['url']
# Auto mode materialises its own inspection — nothing to receive.
s.mode = 'auto'
assert _confirm_action(s) is None
s.mode = 'plan'
# Already confirmed — the button stops appearing in later reminders.
s.acknowledged_at = datetime.now()
assert _confirm_action(s) is None
s.acknowledged_at = None
# Unassigned — nobody to confirm.
s.inspector_id = None
assert _confirm_action(s) is None
def test_email_renders_the_extra_action_button(client, app):
"""notify(extra_action=...) is new plumbing; assert it reaches the body."""
from flask import render_template_string
from app.utils.notifications import _EMAIL_HTML_SINGLE, _EMAIL_TEXT_SINGLE
action = {'label': 'Confirm receipt', 'url': 'https://lts.jqc.app/x/abc'}
with app.test_request_context():
html = render_template_string(_EMAIL_HTML_SINGLE, title='T', body='B',
link='/inspection-schedules',
base_url='https://lts.jqc.app',
extra_action=action)
text = render_template_string(_EMAIL_TEXT_SINGLE, title='T', body='B',
link='/inspection-schedules',
base_url='https://lts.jqc.app',
extra_action=action)
assert 'Confirm receipt' in html and action['url'] in html
assert 'Confirm receipt' in text and action['url'] in text
# The generic link still renders alongside it.
assert 'View Details' in html
# And without extra_action nothing changes for every other caller.
with app.test_request_context():
plain = render_template_string(_EMAIL_HTML_SINGLE, title='T', body='B',
link='/x', base_url='http://h',
extra_action=None)
assert 'Confirm receipt' not in plain
assert 'View Details' in plain
+37 -21
View File
@@ -25,6 +25,18 @@ from datetime import date, datetime, timedelta
import pytest
def _today():
"""Today in the app's timezone, not the machine's.
The routes validate due dates against now_eastern(). Using _today() here
made these tests fail on a UTC host between 20:00 ET and midnight, when the
two calendars disagree "yesterday" by UTC is still today in Eastern, so a
date the test expected to be rejected was legitimately accepted.
"""
from app.utils.time_utils import now_eastern
return now_eastern().date()
@pytest.fixture
def client(app):
"""Fresh schema + test client for each test (isolated in-memory DB)."""
@@ -81,11 +93,12 @@ def _post_follow_up(client, user, **body):
# ── Creation ─────────────────────────────────────────────────────────────────
def test_follow_up_creates_one_time_plan_schedule_from_the_parent(client):
from app import db
from app.models.inspection_schedule import InspectionSchedule
user, tmpl, fac = _seed('mk', role='project_manager')
parent = _completed_inspection(user, tmpl, fac)
due = date.today() + timedelta(days=7)
due = _today() + timedelta(days=7)
resp = _post_follow_up(client, user,
parent_inspection_id=parent.id,
@@ -95,7 +108,7 @@ def test_follow_up_creates_one_time_plan_schedule_from_the_parent(client):
data = resp.get_json()['data']
assert data['created'] is True
s = InspectionSchedule.query.get(data['scheduled']['id'])
s = db.session.get(InspectionSchedule, data['scheduled']['id'])
assert s.parent_inspection_id == parent.id
assert s.is_follow_up is True
# Everything derived from the parent, nothing taken from the client.
@@ -130,11 +143,12 @@ def test_follow_up_ignores_client_supplied_facility_and_template(client):
resp = _post_follow_up(client, user,
parent_inspection_id=parent.id,
due_date=(date.today() + timedelta(days=3)).isoformat(),
due_date=(_today() + timedelta(days=3)).isoformat(),
facility_id=other_fac.id,
template_id=other_tmpl.id,
frequency='daily', mode='auto')
s = InspectionSchedule.query.get(resp.get_json()['data']['scheduled']['id'])
s = db.session.get(InspectionSchedule,
resp.get_json()['data']['scheduled']['id'])
assert s.facility_id == fac.id
assert s.template_id == tmpl.id
assert s.frequency == 'once'
@@ -146,8 +160,8 @@ def test_follow_up_is_idempotent_on_retry(client):
user, tmpl, fac = _seed('idem', role='admin')
parent = _completed_inspection(user, tmpl, fac)
first_due = date.today() + timedelta(days=5)
second_due = date.today() + timedelta(days=9)
first_due = _today() + timedelta(days=5)
second_due = _today() + timedelta(days=9)
r1 = _post_follow_up(client, user, parent_inspection_id=parent.id,
due_date=first_due.isoformat())
@@ -171,14 +185,14 @@ def test_follow_up_reschedule_rearms_reminders(client):
user, tmpl, fac = _seed('rearm', role='admin')
parent = _completed_inspection(user, tmpl, fac)
_post_follow_up(client, user, parent_inspection_id=parent.id,
due_date=(date.today() + timedelta(days=2)).isoformat())
due_date=(_today() + timedelta(days=2)).isoformat())
s = InspectionSchedule.query.filter_by(parent_inspection_id=parent.id).one()
s.advance_notified = s.due_notified = s.overdue_notified = True
db.session.commit()
_post_follow_up(client, user, parent_inspection_id=parent.id,
due_date=(date.today() + timedelta(days=12)).isoformat())
due_date=(_today() + timedelta(days=12)).isoformat())
db.session.expire_all()
s = InspectionSchedule.query.filter_by(parent_inspection_id=parent.id).one()
assert s.advance_notified is False
@@ -201,14 +215,14 @@ def test_follow_up_requires_a_completed_parent(client):
db.session.commit()
r = _post_follow_up(client, user, parent_inspection_id=draft.id,
due_date=(date.today() + timedelta(days=1)).isoformat())
due_date=(_today() + timedelta(days=1)).isoformat())
assert r.status_code == 400
def test_follow_up_rejects_bad_input(client):
user, tmpl, fac = _seed('bad', role='admin')
parent = _completed_inspection(user, tmpl, fac)
ok_due = (date.today() + timedelta(days=1)).isoformat()
ok_due = (_today() + timedelta(days=1)).isoformat()
# Missing parent id.
assert _post_follow_up(client, user, due_date=ok_due).status_code == 400
@@ -222,7 +236,7 @@ def test_follow_up_rejects_bad_input(client):
assert _post_follow_up(client, user, parent_inspection_id=parent.id,
due_date='next tuesday').status_code == 400
# Past date.
past = (date.today() - timedelta(days=1)).isoformat()
past = (_today() - timedelta(days=1)).isoformat()
assert _post_follow_up(client, user, parent_inspection_id=parent.id,
due_date=past).status_code == 400
@@ -232,7 +246,7 @@ def test_follow_up_allows_today(client):
user, tmpl, fac = _seed('today', role='admin')
parent = _completed_inspection(user, tmpl, fac)
r = _post_follow_up(client, user, parent_inspection_id=parent.id,
due_date=date.today().isoformat())
due_date=_today().isoformat())
assert r.status_code == 201
@@ -241,7 +255,7 @@ def test_follow_up_rejects_auditor(client):
user, tmpl, fac = _seed('aud', role='auditor')
parent = _completed_inspection(user, tmpl, fac)
r = _post_follow_up(client, user, parent_inspection_id=parent.id,
due_date=(date.today() + timedelta(days=1)).isoformat())
due_date=(_today() + timedelta(days=1)).isoformat())
assert r.status_code == 403
@@ -259,7 +273,7 @@ def test_inspector_cannot_follow_up_someone_elses_inspection(client):
db.session.commit()
r = _post_follow_up(client, other, parent_inspection_id=parent.id,
due_date=(date.today() + timedelta(days=1)).isoformat())
due_date=(_today() + timedelta(days=1)).isoformat())
assert r.status_code == 403
@@ -268,7 +282,7 @@ def test_follow_up_requires_auth(client):
parent = _completed_inspection(user, tmpl, fac)
r = client.post('/api/v1/scheduled-inspections/follow-up',
json={'parent_inspection_id': parent.id,
'due_date': date.today().isoformat()})
'due_date': _today().isoformat()})
assert r.status_code == 401
@@ -284,7 +298,7 @@ def test_web_start_inherits_the_parent_link(client):
user, tmpl, fac = _seed('start', role='admin')
parent = _completed_inspection(user, tmpl, fac)
_post_follow_up(client, user, parent_inspection_id=parent.id,
due_date=date.today().isoformat())
due_date=_today().isoformat())
sched = InspectionSchedule.query.filter_by(parent_inspection_id=parent.id).one()
client.post('/auth/login', data={'username': user.username,
@@ -307,7 +321,7 @@ def test_web_start_resumes_instead_of_duplicating(client):
user, tmpl, fac = _seed('resume', role='admin')
parent = _completed_inspection(user, tmpl, fac)
_post_follow_up(client, user, parent_inspection_id=parent.id,
due_date=date.today().isoformat())
due_date=_today().isoformat())
sched = InspectionSchedule.query.filter_by(parent_inspection_id=parent.id).one()
client.post('/auth/login', data={'username': user.username,
@@ -352,13 +366,14 @@ def test_cron_materialiser_inherits_the_parent_link(client):
def test_api_create_infers_parent_from_the_schedule(client):
"""An older iPad build submits the schedule id but no parent. Without the
inference the parent would stay flagged forever."""
from app import db
from app.models.inspection import Inspection
from app.models.inspection_schedule import InspectionSchedule
user, tmpl, fac = _seed('infer', role='admin')
parent = _completed_inspection(user, tmpl, fac)
_post_follow_up(client, user, parent_inspection_id=parent.id,
due_date=date.today().isoformat())
due_date=_today().isoformat())
sched = InspectionSchedule.query.filter_by(parent_inspection_id=parent.id).one()
resp = client.post('/api/v1/inspections', headers=_auth(user), json={
@@ -369,13 +384,14 @@ def test_api_create_infers_parent_from_the_schedule(client):
assert resp.status_code in (200, 201)
new_id = resp.get_json()['data']['inspection_id']
run = Inspection.query.get(new_id)
run = db.session.get(Inspection, new_id)
assert run.parent_inspection_id == parent.id
# And the whole point of the link: the parent's flag is cleared.
assert parent.follow_up_required is False
def test_api_explicit_parent_wins_over_the_schedule(client):
from app import db
from app.models.inspection import Inspection
from app.models.inspection_schedule import InspectionSchedule
@@ -383,7 +399,7 @@ def test_api_explicit_parent_wins_over_the_schedule(client):
parent_a = _completed_inspection(user, tmpl, fac)
parent_b = _completed_inspection(user, tmpl, fac)
_post_follow_up(client, user, parent_inspection_id=parent_a.id,
due_date=date.today().isoformat())
due_date=_today().isoformat())
sched = InspectionSchedule.query.filter_by(parent_inspection_id=parent_a.id).one()
resp = client.post('/api/v1/inspections', headers=_auth(user), json={
@@ -393,7 +409,7 @@ def test_api_explicit_parent_wins_over_the_schedule(client):
'form_data': {'f1': 5},
})
new_id = resp.get_json()['data']['inspection_id']
assert Inspection.query.get(new_id).parent_inspection_id == parent_b.id
assert db.session.get(Inspection, new_id).parent_inspection_id == parent_b.id
def test_ordinary_schedule_produces_no_parent_link(client):