Jul 21 - Update QR code destination page, shows recent inspection cleaning quality

This commit is contained in:
2026-07-21 12:00:27 -04:00
parent fc366fbf8d
commit 507d9b5c64
4 changed files with 47 additions and 24 deletions
+2 -2
View File
@@ -208,7 +208,7 @@ areas: id, facility_id (FK), name, area_type,
**`public_token`** (Phase 34): unguessable per-facility token encoded in the facility's QR code. The QR points at `/f/<public_token>` — a **login-free** occupant summary page. `Facility.generate_public_token()` / `ensure_public_token()` mint one on demand; new facilities get one at creation, existing rows were backfilled by phase34. Rotating the token (regenerating it) invalidates any printed QR — intentional, for when a code is compromised.
**`Area.public_token`** (Phase 39): the same pattern applied per area. The QR points at `/f/area/<public_token>` — a **login-free** occupant summary scoped to that single area (its own avg score / inspection count / open-issue count / trend / recent inspection dates), with a "report a problem" form that files the issue with `area_id` set. `Area.generate_public_token()` / `ensure_public_token()` mirror the Facility methods; new areas get a token at creation, existing rows backfilled by phase39. Both public pages obey rule 74 (aggregate + dates only — never checklist names, per-inspection scores, or severity/SLA). Routing: `/f/area/<token>` and `/f/<token>` do not collide (tokens are single-segment; `area` is a literal first segment).
**`Area.public_token`** (Phase 39): the same pattern applied per area. The QR points at `/f/area/<public_token>` — a **login-free** occupant summary scoped to that single area (its own avg score / inspection count / open-issue count / trend / recent inspection dates), with a "report a problem" form that files the issue with `area_id` set. `Area.generate_public_token()` / `ensure_public_token()` mirror the Facility methods; new areas get a token at creation, existing rows backfilled by phase39. Both public pages obey rule 74 (aggregate quality only: rating, counts, trend, and recent inspections with date + quality label — never raw score percentages, checklist/template names, inspector names, per-item scores, or severity/SLA). Routing: `/f/area/<token>` and `/f/<token>` do not collide (tokens are single-segment; `area` is a literal first segment).
**`area_type` choices:** `restroom`, `lobby`, `hallway`, `office`, `kitchen`, `storage`, `floor`, `outdoor`, `other`
@@ -1344,7 +1344,7 @@ timeout = 30
| 71 | **`ProxyFix` must wrap `app.wsgi_app` in `create_app()`** | Behind Nginx, `remote_addr` is `127.0.0.1` for every request without it, collapsing all Flask-Limiter keys into one bucket (global instead of per-client rate limiting). `x_for=1` trusts exactly one proxy hop. See §19. |
| 72 | **Device registration has exactly ONE implementation — `register_device()` in `app/api/auth.py` → `api_device_tokens`** | A second `POST /api/v1/devices/register` (`app/api/devices.py` + `DeviceRegistration` model) was removed July 2026. It was shadowed by the `api_auth` route at routing time and queried the dropped `device_registrations` table. Do not reintroduce a competing device model or duplicate register route. |
| 73 | **Per-contract recipients are dispatched ONLY inside `notify_by_matrix()` — never add a parallel path** | `_notify_contract_recipients()` runs after role + global-custom-email routing and shares the `notified` / `sent_emails` dedup sets. Any new event that should reach contract recipients must go through `notify_by_matrix()` (passing `facility_id`, or an `issue_id`/`inspection_id` that resolves to one). Bypassing it means contract recipients are silently skipped and dedup breaks. Commit stays the caller's responsibility. |
| 74 | **The `public` blueprint (`/f/*`) is login-free — keep it occupant-safe** | Pages are addressed by unguessable `public_token` (never facility id), 404 on inactive/unknown facilities, and expose only a quality rating, last-inspected date, and open-issue COUNT — **never** issue descriptions, inspector names, per-item scores, or any other facility's data. The `report` POST must stay CSRF-protected (Flask-WTF form), rate-limited, honeypot-guarded, and **idempotency-guarded** (`_recent_duplicate_report()` — an identical public report for the same facility/area within `DUPLICATE_REPORT_WINDOW_SECONDS`=60s is silently accepted as success without creating a second issue or saving its photos; the dedup check runs BEFORE `_save_report_photos()` to avoid orphaned uploads). The client also disables the submit button on first tap. Public-reported issues are created with `reported_by=NULL`, `severity='medium'`, and routed through `notify_by_matrix('issue_created', facility_id=...)`. **Photos:** the report form accepts **up to 5 photos** (`PublicIssueReportForm.photos`, a `MultipleFileField`); `_save_report_photos()` in `public.py` saves them via the shared magic-byte-validated `_save_photo()` (cap `MAX_REPORT_PHOTOS=5`) and stores the first in `Issue.photo_path`, the rest in `Issue.mobile_photo_paths` — never `result_photos` (rule 44), so they all render under "Photo Evidence". Do not add fields that leak internal detail, and do not reuse `render_template('base.html')` here — the public page is a standalone template with no authenticated nav. |
| 74 | **The `public` blueprint (`/f/*`) is login-free — keep it occupant-safe** | Pages are addressed by unguessable `public_token` (never facility id), 404 on inactive/unknown facilities, and expose only aggregate quality data: the overall rating, inspection/issue COUNTS, the score trend, and a **Recent Inspections** list showing each inspection's date + quality **label** (July 2026 — `_recent_rows()` shapes these). `_recent_rows()` deliberately does **not** put the raw score in the payload, so the percentage cannot leak into the rendered page; `_rating_label(None)` yields "Not yet rated" so unscored inspections render safely. Still **never**: raw per-inspection score percentages, issue descriptions, inspector names, checklist/template names, per-checklist-item scores, severity/SLA detail, or any other facility's data. The `report` POST must stay CSRF-protected (Flask-WTF form), rate-limited, honeypot-guarded, and **idempotency-guarded** (`_recent_duplicate_report()` — an identical public report for the same facility/area within `DUPLICATE_REPORT_WINDOW_SECONDS`=60s is silently accepted as success without creating a second issue or saving its photos; the dedup check runs BEFORE `_save_report_photos()` to avoid orphaned uploads). The client also disables the submit button on first tap. Public-reported issues are created with `reported_by=NULL`, `severity='medium'`, and routed through `notify_by_matrix('issue_created', facility_id=...)`. **Photos:** the report form accepts **up to 5 photos** (`PublicIssueReportForm.photos`, a `MultipleFileField`); `_save_report_photos()` in `public.py` saves them via the shared magic-byte-validated `_save_photo()` (cap `MAX_REPORT_PHOTOS=5`) and stores the first in `Issue.photo_path`, the rest in `Issue.mobile_photo_paths` — never `result_photos` (rule 44), so they all render under "Photo Evidence". Do not add fields that leak internal detail, and do not reuse `render_template('base.html')` here — the public page is a standalone template with no authenticated nav. |
| 75 | **Email is stored lowercased; look it up case-insensitively** | User/customer email is normalized to `.strip().lower()` at every write site (`auth.py` profile/create/edit, `customers.py` invite/edit). Forgot-password lookup uses `db.func.lower(User.email) == input` so a mixed-case legacy row still matches — a plain `filter_by(email=...)` silently missed them and sent no reset (the failure was invisible because of the generic "if an account exists…" message). Keep both halves: normalize on write, case-insensitive on lookup. |
| 76 | **Transactional email `From` must be an SMTP-authorized identity, per-domain branding via display name only** | Reset-password sends from `MAIL_DEFAULT_SENDER`; customer invite sends from `branded_sender()` = `(per-domain display name, authorized address)`. A per-host `noreply@<subdomain>` sender is accepted by the relay then dropped by SPF/DMARC. See rule 64 and §8 `mail_utils.py`. |
| 77 | **`GET /api/v1/scheduled-inspections` is inspector-scoped by `inspector_id`, admin/director/PM see all** | New `app/api/scheduled.py` blueprint. Register in `app/api/__init__.py` AND `csrf.exempt(_api_scheduled_bp)` in `app/__init__.py` — the child-blueprint CSRF exemption never cascades from the parent. Read-only; do not add write/fulfil endpoints here (the schedule lifecycle stays in `routes/scheduled_inspections.py`). |
+30 -7
View File
@@ -110,12 +110,34 @@ def _rating_label(score):
return ('Needs attention', 'danger')
def _recent_rows(inspections):
"""Shape recent completed inspections for the occupant page.
Each row carries the date and the occupant-friendly quality label/colour.
The raw score percentage is deliberately NOT included in the payload the
occupant sees the quality label only, so the number cannot leak into the
rendered page. Also excludes inspector names, template/checklist names,
and any per-item detail (occupant-safe).
"""
rows = []
for i in inspections:
score = float(i.overall_score) if i.overall_score is not None else None
label, colour = _rating_label(score) # None -> 'Not yet rated'
rows.append({
'date': i.inspection_date,
'label': label,
'colour': colour,
})
return rows
def _build_summary(facility: Facility) -> dict:
"""Assemble the occupant-facing summary for a facility.
Occupant-safe (rule 74): aggregate rating, counts, a score trend, and
recent inspection DATES only no checklist/template names, no
per-inspection scores, no issue descriptions, and no severity/SLA detail.
recent inspections showing date + quality LABEL only (no score
percentage) no checklist/template names, no inspector names, no issue
descriptions, and no per-item or severity/SLA detail.
"""
fid = facility.id
now = now_eastern()
@@ -172,7 +194,8 @@ def _build_summary(facility: Facility) -> dict:
else:
trend_delta = None
# Recent inspection DATES only (no checklist names, no scores)
# Recent inspections: date + score + quality label (no checklist/template
# names, no inspector names, no per-item detail).
recent = (
Inspection.query
.filter(Inspection.facility_id == fid,
@@ -181,7 +204,7 @@ def _build_summary(facility: Facility) -> dict:
.limit(5)
.all()
)
recent_dates = [i.inspection_date for i in recent]
recent_inspections = _recent_rows(recent)
# Open-issue COUNT (linked directly or via an area) — no details exposed
open_issue_count = (
@@ -215,7 +238,7 @@ def _build_summary(facility: Facility) -> dict:
'resolved_90': resolved_90,
'trend_delta': trend_delta,
'last_inspected': last_insp.inspection_date if last_insp else None,
'recent_dates': recent_dates,
'recent_inspections': recent_inspections,
}
@@ -297,7 +320,7 @@ def _build_area_summary(area, facility) -> dict:
.limit(5)
.all()
)
recent_dates = [i.inspection_date for i in recent]
recent_inspections = _recent_rows(recent)
open_issue_count = (
Issue.query
@@ -328,7 +351,7 @@ def _build_area_summary(area, facility) -> dict:
'resolved_90': resolved_90,
'trend_delta': trend_delta,
'last_inspected': last_insp.inspection_date if last_insp else None,
'recent_dates': recent_dates,
'recent_inspections': recent_inspections,
}
+6 -6
View File
@@ -102,14 +102,14 @@
<span class="badge bg-{{ rating_colour }} fs-6">{{ rating_label }}</span>
</div>
{# ── Recent inspections (DATES ONLY — occupant-safe) ── #}
{% if recent_dates %}
{# ── Recent inspections (date + quality label, no % — occupant-safe) ── #}
{% if recent_inspections %}
<div class="card-soft p-3 mb-3">
<div class="sec-title mb-2"><i class="bi bi-clipboard-check me-1"></i> Recent Inspections</div>
{% for d in recent_dates %}
<div class="list-line py-2 d-flex align-items-center justify-content-between">
<span>{{ d.strftime('%b %d, %Y') }}</span>
<span class="text-success small"><i class="bi bi-check-circle-fill"></i> Completed</span>
{% for r in recent_inspections %}
<div class="list-line py-2 d-flex align-items-center justify-content-between gap-2">
<span>{{ r.date.strftime('%b %d, %Y') }}</span>
<span class="badge bg-{{ r.colour }}">{{ r.label }}</span>
</div>
{% endfor %}
<div class="text-muted small mt-2">This area is inspected regularly by our quality team.</div>
+5 -5
View File
@@ -103,13 +103,13 @@
</div>
{# ── Recent inspections (DATES ONLY — occupant-safe) ── #}
{% if recent_dates %}
{% if recent_inspections %}
<div class="card-soft p-3 mb-3">
<div class="sec-title mb-2"><i class="bi bi-clipboard-check me-1"></i> Recent Inspections</div>
{% for d in recent_dates %}
<div class="list-line py-2 d-flex align-items-center justify-content-between">
<span>{{ d.strftime('%b %d, %Y') }}</span>
<span class="text-success small"><i class="bi bi-check-circle-fill"></i> Completed</span>
{% for r in recent_inspections %}
<div class="list-line py-2 d-flex align-items-center justify-content-between gap-2">
<span>{{ r.date.strftime('%b %d, %Y') }}</span>
<span class="badge bg-{{ r.colour }}">{{ r.label }}</span>
</div>
{% endfor %}
<div class="text-muted small mt-2">This facility is inspected regularly by our quality team.</div>