Jul 21 - Update QR code destination page, shows recent inspection cleaning quality
This commit is contained in:
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user