From faab9fd0083cb27300842243b3d4f26e189b6e39 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Tue, 7 Jul 2026 21:05:07 -0400 Subject: [PATCH] Jul 7 - Implement QR codes for facility --- CLAUDE.md | 26 ++- app/__init__.py | 2 + app/models/facility.py | 11 ++ app/routes/facilities.py | 81 +++++++++- app/routes/facility_qr.py | 156 ++++++++++++++++++ app/templates/facilities/list.html | 5 + app/templates/facilities/qr_card.html | 75 +++++++++ app/templates/facilities/qr_sheet.html | 67 ++++++++ app/templates/facilities/view.html | 6 + app/templates/facility_qr/view.html | 180 +++++++++++++++++++++ app/utils/qr.py | 24 +++ migrations/versions/phase38_facility_qr.py | 58 +++++++ 12 files changed, 684 insertions(+), 7 deletions(-) create mode 100644 app/routes/facility_qr.py create mode 100644 app/templates/facilities/qr_card.html create mode 100644 app/templates/facilities/qr_sheet.html create mode 100644 app/templates/facility_qr/view.html create mode 100644 app/utils/qr.py create mode 100644 migrations/versions/phase38_facility_qr.py diff --git a/CLAUDE.md b/CLAUDE.md index 8efb2fc..1bfdf20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -280,12 +280,15 @@ users: id, username (unique, indexed), full_name, email (unique, indexed), ### Facility / Area ``` -facilities: id, name, address, contact_person, contact_phone, active, project_id (FK) +facilities: id, name, address, contact_person, contact_phone, active, project_id (FK), + qr_token VARCHAR(64) UNIQUE NULL ← phase38 areas: id, facility_id (FK), name, area_type ``` **`area_type` choices:** `restroom`, `lobby`, `hallway`, `office`, `kitchen`, `storage`, `floor`, `outdoor`, `other` +**`qr_token` (phase38):** Unguessable token (`secrets.token_urlsafe(32)`) behind the public facility QR scan page `GET /f/` (blueprint `facility_qr`, rule 91). NULL until first requested — `Facility.ensure_qr_token()` generates it lazily when staff open the QR card (`/facilities//qr`) or bulk print sheet (`/facilities/qr-sheet`). Regenerating (`POST /facilities//qr/regenerate`, `@supervisor_required`, audited) invalidates all previously printed posters. + ### Project / CustomerAssignment ``` @@ -498,7 +501,8 @@ The `DeviceRegistration` model and the duplicate `api_devices` blueprint were ** |---|---|---| | `auth` | `/auth` | `/login`, `/logout`, `/profile`, `/users/*`, `/notification-matrix`, `/mfa` (login 2FA challenge), `/mfa/setup` + `/mfa/disable` (phase35, `@supervisor_required` enroll/disable) | | `dashboard` | `/` | `GET /`, `/facility-trend` (AJAX) | -| `facilities` | `/facilities` | CRUD + area management | +| `facilities` | `/facilities` | CRUD + area management + QR codes (phase38): `GET //qr` printable card + `GET /qr-sheet` bulk print (`@project_manager_required`), `POST //qr/regenerate` (`@supervisor_required`) | +| `facility_qr` | `/f` | phase38 — **public, login-less** facility QR scan page: `GET /` shows counts-and-scores-only snapshot (90-day stats, 30-day score trend, open-issue severity/SLA counts, recent inspection scores). Token is the authorization (rule 91). Hybrid: logged-in scanners with facility scope get a link to the full internal view | | `projects` | `/projects` | CRUD + customer assignment management + per-contract notification recipients (`GET //recipients`, `POST //recipients/add`, `POST /recipients//remove` — `@supervisor_required`, phase37) | | `customers` | `/customers` | list, invite, set-password, manage, import CSV | | `inspections` | `/inspections` | list, start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect, upload-photo (AJAX) | @@ -781,7 +785,7 @@ limiter = Limiter( ## 17. Alembic Migration Chain -**Current HEAD:** `phase37_contract_recipients` (35 migrations total). +**Current HEAD:** `phase38_facility_qr` (36 migrations total). **Chain root:** `0003_add_user_active` — a guarded squashed baseline (MT-2) that recreates the full 25-table schema with INFORMATION_SCHEMA guards. The original baseline migrations (0001/0002/0003) were lost; this file restores the chain root so Alembic can build the revision map. `down_revision = None`. @@ -813,7 +817,18 @@ limiter = Limiter( → phase34_inspection_schedules → phase35_user_mfa → phase36_issue_work_orders - → phase37_contract_recipients ← HEAD + → phase37_contract_recipients + → phase38_facility_qr ← HEAD +``` + +### phase38_facility_qr + +Adds `facilities.qr_token VARCHAR(64) NULL` + unique index `uq_facilities_qr_token`. Backs the public facility QR scan page (`GET /f/` — see §5 `qr_token` + the `facility_qr` blueprint + rule 91). NULL for existing rows; tokens generate lazily via `Facility.ensure_qr_token()` when staff first print a QR card/sheet. Guarded with `INFORMATION_SCHEMA` column + index existence checks — safe to re-run. + +**Deploy order:** +```bash +flask db upgrade +sudo systemctl restart gunicorn ``` ### phase37_contract_recipients @@ -1334,6 +1349,7 @@ set -a; . /etc/jqc/control.env; set +a | 89 | **Vendor work-order pages are public and token-authorized — the token IS the credential** | `GET/POST /work-orders/` have NO `@login_required`; the unguessable `secrets.token_urlsafe(32)` token is the sole authorization, so never render one in any staff-visible page, log line, or list except in the contractor's own emailed link. Rate-limited (`60/hr` view, `20/hr` update). The public page shows only scoped issue details (facility, area, description, severity, staff message) — never internal notes/comments/assignees. State transitions are one-way and guarded (`sent→acknowledged→completed`); a completed order ignores further actions. Completing an order sets the parent issue to `pending_verification` (staff still sign off — the vendor cannot self-resolve). In MT mode the link resolves to the right tenant by Host, so the route is NOT tenant-exempt. | | 88 | **Password strength enforced by one shared `strong_password()` validator** | Lives in `app/utils/forms.py`: ≥8 chars, at least one letter AND one digit, and not in a small common-password blocklist. Applied to every password-setting form — `ProfileForm`, `UserForm`, `CustomerForm`, `ResetPasswordForm`, `SetPasswordForm`, and `signup.SignupForm` (imports it). Sits after `Optional()` on edit forms (skips blank = "leave unchanged"). Do not re-introduce ad-hoc `Length(min=6)` password rules — route new password fields through `strong_password()` so the policy stays consistent. | | 90 | **Per-contract recipients dispatch INSIDE `notify_by_matrix()` — never call `_notify_project_recipients()` from routes** | phase37. Contract-scoped recipients (`ProjectNotificationRecipient`) are dispatched automatically at the end of `notify_by_matrix()`, after matrix roles + global custom emails, with dedup against both. The contract is resolved from `facility_id` arg → `issue.resolved_facility` → `inspection.facility_id`; events fired without any facility context reach matrix recipients only. New `notify_by_matrix()` call sites should pass `facility_id` (or `issue_id`/`inspection_id`) so contract recipients fire. The `score_alert` cron call in `sla.py` now passes `facility_id=fid` for this reason (side effect: if the matrix ever enables `customer` for `score_alert`, customers are facility-scoped instead of org-wide — a strict improvement). Staff recipients use `respect_preferences=False` (contract config is the authority, same as matrix broadcasts). | +| 91 | **Facility QR scan page is public and token-authorized — counts + scores ONLY** | phase38, same authorization class as rule 89: `GET /f/` has NO `@login_required`; the unguessable `facilities.qr_token` is the sole credential, because QR posters hang in public hallways. The page must NEVER render free-text issue descriptions, inspector/staff names, photos, or comments — only aggregate counts, scores, dates, template names, and severity/SLA counts. Rate-limited `60/hr`. Inactive facilities 404. The hybrid full-view button appears only when `current_user` is authenticated AND their role scope covers the facility (`_can_view_full()` — staff always; inspector/customer via scope utils); the internal page re-enforces scope anyway. QR URLs are built from `request.host_url` (rule 64 pattern) so each tenant's posters carry its own domain — the route resolves by Host and is NOT tenant-exempt. `qr_svg()` lives in `app/utils/qr.py` (general-purpose; the TOTP-specific `mfa.qr_svg()` mirrors stay untouched). Rotate a leaked poster with `POST /facilities//qr/regenerate`. | --- @@ -1691,7 +1707,7 @@ Ask: Does this change break any other code path that uses the modified function, **Rule 13 — List every file changed** with the exact location of each change (function name and what was modified). **Rule 14 — Migrations are required for any schema change.** -Follow the `phase{N}_description.py` naming convention. The new migration's `down_revision` must point to the current HEAD (`phase37_contract_recipients`). **Revision ids must be ≤ 32 characters** — `alembic_version.version_num` is `VARCHAR(32)`; a longer id passes every migration step and then fails the final version-pointer UPDATE with MySQL error 1406 (`Data too long for column 'version_num'`), leaving the DDL applied (auto-committed) but the version stamp still on the previous revision. Use `INFORMATION_SCHEMA` existence checks so migrations are safe to re-run. Never use `batch_alter_table` for MySQL. +Follow the `phase{N}_description.py` naming convention. The new migration's `down_revision` must point to the current HEAD (`phase38_facility_qr`). **Revision ids must be ≤ 32 characters** — `alembic_version.version_num` is `VARCHAR(32)`; a longer id passes every migration step and then fails the final version-pointer UPDATE with MySQL error 1406 (`Data too long for column 'version_num'`), leaving the DDL applied (auto-committed) but the version stamp still on the previous revision. Use `INFORMATION_SCHEMA` existence checks so migrations are safe to re-run. Never use `batch_alter_table` for MySQL. Self-contained package, own `ControlBase` + engine/session, own Alembic chain. No imports from `app/`. diff --git a/app/__init__.py b/app/__init__.py index 8a43b99..46850b3 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -203,6 +203,7 @@ def create_app(config_name='default'): from app.routes import scheduled_reports # Phase 6 — Scheduled reports from app.routes import inspection_schedules # phase34 — recurring inspections from app.routes import work_orders # phase36 — vendor work orders (public tokenized) + from app.routes import facility_qr # phase38 — facility QR scan page (public tokenized) from app.routes import support # Support chat + admin tickets from app.routes import broadcast # Admin broadcast messages from app.routes import devices # Admin device management @@ -225,6 +226,7 @@ def create_app(config_name='default'): app.register_blueprint(scheduled_reports.bp) app.register_blueprint(inspection_schedules.bp) app.register_blueprint(work_orders.bp) + app.register_blueprint(facility_qr.bp) app.register_blueprint(support.bp) app.register_blueprint(broadcast.bp) app.register_blueprint(devices.bp) diff --git a/app/models/facility.py b/app/models/facility.py index 6634224..27a2848 100644 --- a/app/models/facility.py +++ b/app/models/facility.py @@ -16,10 +16,21 @@ class Facility(db.Model): project_id = db.Column(db.Integer, db.ForeignKey('projects.id', ondelete='SET NULL'), nullable=True, index=True) + # phase38: unguessable token behind the public QR scan page (/f/). + # NULL until first requested — ensure_qr_token() generates it lazily. + qr_token = db.Column(db.String(64), unique=True, nullable=True) + # Relationships areas = db.relationship('Area', backref='facility', lazy='dynamic') inspections = db.relationship('Inspection', backref='facility', lazy='dynamic') + def ensure_qr_token(self): + """Generate the QR token on first use. Caller commits.""" + if not self.qr_token: + import secrets + self.qr_token = secrets.token_urlsafe(32) + return self.qr_token + def __repr__(self): return f'' diff --git a/app/routes/facilities.py b/app/routes/facilities.py index 9f1caad..757dfdf 100644 --- a/app/routes/facilities.py +++ b/app/routes/facilities.py @@ -5,7 +5,7 @@ from app import db from app.models.facility import Facility, Area from app.models.project import Project from app.utils.forms import FacilityForm, AreaForm -from app.utils.decorators import supervisor_required, admin_required +from app.utils.decorators import supervisor_required, admin_required, project_manager_required from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE from app.utils.scope import get_customer_scope, get_inspector_scope from app.tenancy.gates import quota_soft_check @@ -237,4 +237,81 @@ def delete_area(area_id): current_user.username, area_id_snap, area_name) log_action(ACTION_DELETE, 'Area', area_id_snap, area_name) flash(f'Area "{area_name}" deleted successfully.', 'success') - return redirect(url_for('facilities.view_facility', facility_id=facility_id)) \ No newline at end of file + return redirect(url_for('facilities.view_facility', facility_id=facility_id)) + + +# ── Facility QR codes (phase38) ─────────────────────────────────────────────── + +def _qr_scan_url(facility): + """Absolute public scan URL, built from the current host (rule 64 pattern).""" + return request.host_url.rstrip('/') + url_for('facility_qr.scan', + token=facility.qr_token) + + +@bp.route('//qr') +@login_required +@project_manager_required +def qr_card(facility_id): + """Printable QR card for one facility. Generates the token on first use.""" + from app.utils.qr import qr_svg + + facility = db.session.get(Facility, facility_id) + if facility is None: + abort(404) + + if not facility.qr_token: + facility.ensure_qr_token() + db.session.commit() + logger.info('FACILITIES | qr_token_created | user=%s | facility_id=%s', + current_user.username, facility_id) + + scan_url = _qr_scan_url(facility) + return render_template('facilities/qr_card.html', + facility=facility, + scan_url=scan_url, + svg=qr_svg(scan_url)) + + +@bp.route('/qr-sheet') +@login_required +@project_manager_required +def qr_sheet(): + """Bulk print sheet — one labeled QR card per active facility.""" + from app.utils.qr import qr_svg + + facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all() + + generated = 0 + for f in facilities: + if not f.qr_token: + f.ensure_qr_token() + generated += 1 + if generated: + db.session.commit() + logger.info('FACILITIES | qr_tokens_created | user=%s | count=%s', + current_user.username, generated) + + cards = [{'facility': f, 'svg': qr_svg(_qr_scan_url(f))} for f in facilities] + return render_template('facilities/qr_sheet.html', cards=cards) + + +@bp.route('//qr/regenerate', methods=['POST']) +@login_required +@supervisor_required +def regenerate_qr(facility_id): + """Rotate the QR token — invalidates every previously printed poster.""" + import secrets + + facility = db.session.get(Facility, facility_id) + if facility is None: + abort(404) + + facility.qr_token = secrets.token_urlsafe(32) + db.session.commit() + logger.info('FACILITIES | qr_token_regenerated | user=%s | facility_id=%s', + current_user.username, facility_id) + log_action(ACTION_UPDATE, 'Facility', facility.id, facility.name, + 'QR token regenerated — previously printed QR posters are now invalid') + flash('QR code regenerated. Previously printed posters no longer work — ' + 'print and post the new code.', 'success') + return redirect(url_for('facilities.qr_card', facility_id=facility_id)) \ No newline at end of file diff --git a/app/routes/facility_qr.py b/app/routes/facility_qr.py new file mode 100644 index 0000000..3c0b330 --- /dev/null +++ b/app/routes/facility_qr.py @@ -0,0 +1,156 @@ +""" +app/routes/facility_qr.py +------------------------- +Public facility QR scan page (phase38). + +`GET /f/` — public, tokenized, NO login (same authorization model as +vendor work orders, rule 89: the unguessable token IS the credential). Shows a +read-only, counts-and-scores-only snapshot of one facility: + + * summary stats (90 days): completed inspections, average score, + resolved issues, last inspection date + * score trend: last-30-day average vs the prior 30 days (same math as the + score-drop alert in sla.py) + * recent completed inspections: date, template, score — NO inspector names + * open issues: counts by severity + SLA at-risk / breached counts — + NO descriptions, NO photos + +Hybrid access: if the scanner is logged in AND their role scope covers this +facility, a button links to the full internal facility view. + +In multi-tenant mode the printed URL is built from the tenant's own domain +(request.host_url), so the route resolves by Host — NOT tenant-exempt. +""" + +import logging +from datetime import timedelta + +from flask import Blueprint, render_template, abort +from flask_login import current_user +from sqlalchemy import func, or_ + +from app import db, limiter +from app.models.facility import Facility, Area +from app.models.inspection import Inspection +from app.models.issue import Issue +from app.utils.sla import sla_status +from app.utils.scope import get_customer_scope, get_inspector_scope +from app.utils.time_utils import now_eastern + +logger = logging.getLogger(__name__) + +bp = Blueprint('facility_qr', __name__, url_prefix='/f') + +SEVERITY_ORDER = ('critical', 'high', 'medium', 'low') + + +def _can_view_full(facility): + """True when the logged-in scanner's role scope covers this facility.""" + if not current_user.is_authenticated: + return False + if current_user.role in ('admin', 'director', 'project_manager'): + return True + if current_user.role == 'inspector': + return facility.id in (get_inspector_scope(current_user) or []) + if current_user.role == 'customer': + return facility.id in (get_customer_scope(current_user) or []) + return False + + +@bp.route('/') +@limiter.limit('60 per hour') +def scan(token): + facility = Facility.query.filter_by(qr_token=token).first() + if facility is None or not facility.active: + abort(404) + + now = now_eastern() + d30 = now - timedelta(days=30) + d60 = now - timedelta(days=60) + d90 = now - timedelta(days=90) + + completed = Inspection.query.filter( + Inspection.facility_id == facility.id, + Inspection.status == 'completed', + ) + + # ── Summary stats (90 days) ─────────────────────────────────────────── + total_90 = completed.filter(Inspection.inspection_date >= d90).count() + avg_90 = db.session.query(func.avg(Inspection.overall_score)).filter( + Inspection.facility_id == facility.id, + Inspection.status == 'completed', + Inspection.inspection_date >= d90, + Inspection.overall_score.isnot(None), + ).scalar() + + recent = (completed + .order_by(Inspection.inspection_date.desc()) + .limit(8).all()) + last_date = recent[0].inspection_date if recent else None + + # ── Score trend: last 30 days vs prior 30 (mirrors send_score_alerts) ─ + def _avg_between(start, end): + return db.session.query(func.avg(Inspection.overall_score)).filter( + Inspection.facility_id == facility.id, + Inspection.status == 'completed', + Inspection.overall_score.isnot(None), + Inspection.inspection_date >= start, + Inspection.inspection_date < end, + ).scalar() + + avg_cur = _avg_between(d30, now + timedelta(days=1)) + avg_prior = _avg_between(d60, d30) + trend_delta = (float(avg_cur) - float(avg_prior)) \ + if (avg_cur is not None and avg_prior is not None) else None + + # ── Open issues: counts by severity + SLA state (counts only) ───────── + issue_q = (Issue.query + .outerjoin(Area, Issue.area_id == Area.id) + .filter(or_(Issue.facility_id == facility.id, + Area.facility_id == facility.id))) + + open_issues = issue_q.filter( + Issue.status.in_(('open', 'in_progress'))).all() + severity_counts = {s: 0 for s in SEVERITY_ORDER} + sla_at_risk = sla_breached = 0 + for issue in open_issues: + if issue.severity in severity_counts: + severity_counts[issue.severity] += 1 + state = sla_status(issue) + if state == 'at_risk': + sla_at_risk += 1 + elif state == 'breached': + sla_breached += 1 + + pending_verification = issue_q.filter( + Issue.status == 'pending_verification').count() + resolved_90 = issue_q.filter( + Issue.status == 'resolved', + Issue.resolved_at.isnot(None), + Issue.resolved_at >= d90, + ).count() + + logger.info('FACILITY QR SCAN | facility_id=%s | authenticated=%s', + facility.id, current_user.is_authenticated) + + return render_template( + 'facility_qr/view.html', + facility = facility, + contract = facility.project, + total_90 = total_90, + avg_90 = float(avg_90) if avg_90 is not None else None, + last_date = last_date, + recent = recent, + trend_delta = trend_delta, + avg_cur = float(avg_cur) if avg_cur is not None else None, + avg_prior = float(avg_prior) if avg_prior is not None else None, + open_total = len(open_issues), + severity_counts = severity_counts, + severity_order = SEVERITY_ORDER, + sla_at_risk = sla_at_risk, + sla_breached = sla_breached, + pending_verification = pending_verification, + resolved_90 = resolved_90, + can_view_full = _can_view_full(facility), + generated_at = now, + ) diff --git a/app/templates/facilities/list.html b/app/templates/facilities/list.html index f69b509..fdecaba 100644 --- a/app/templates/facilities/list.html +++ b/app/templates/facilities/list.html @@ -8,6 +8,11 @@

Facilities

+ {% if current_user.role in ['admin', 'director', 'project_manager'] %} + + Print QR Codes + + {% endif %} {% if current_user.role in ['admin', 'director'] %} Add Facility diff --git a/app/templates/facilities/qr_card.html b/app/templates/facilities/qr_card.html new file mode 100644 index 0000000..83ced59 --- /dev/null +++ b/app/templates/facilities/qr_card.html @@ -0,0 +1,75 @@ + + + + + + QR Code — {{ facility.name }} + + + + + + + + +{% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} +{% endwith %} + +
+

{{ facility.name }}

+ {% if facility.project %}
{{ facility.project.name }}
{% endif %} + {% if facility.address %}
{{ facility.address }}
{% endif %} + +
{{ svg | safe }}
+ +
+ Scan for the latest inspection results +
+
+ Recent scores, open issues, and quality trend for this facility. +
+
{{ scan_url }}
+
+ +{% if current_user.role in ['admin', 'director'] %} +
+
+ + +
Use this if a printed poster leaked or was posted somewhere it shouldn't be.
+
+
+{% endif %} + + + diff --git a/app/templates/facilities/qr_sheet.html b/app/templates/facilities/qr_sheet.html new file mode 100644 index 0000000..5377fac --- /dev/null +++ b/app/templates/facilities/qr_sheet.html @@ -0,0 +1,67 @@ + + + + + + Facility QR Codes — Print Sheet + + + + + +
+ +
+

Facility QR Codes ({{ cards|length }})

+
+ + Back to Facilities + + +
+
+ +

+ Cut along the dashed borders and post each code at its facility. + Scanning shows recent inspection scores, open-issue counts, and the quality trend. +

+ +
+ {% for c in cards %} +
+
+ {{ c.svg | safe }} +
{{ c.facility.name }}
+
+ {% if c.facility.project %}{{ c.facility.project.name }}
{% endif %} + Scan for the latest inspection results +
+
+
+ {% endfor %} +
+ + {% if not cards %} +
No active facilities to print.
+ {% endif %} + +
+ + diff --git a/app/templates/facilities/view.html b/app/templates/facilities/view.html index 8bf212e..44d9cd4 100644 --- a/app/templates/facilities/view.html +++ b/app/templates/facilities/view.html @@ -17,6 +17,12 @@ Scorecard {% endif %} + {% if current_user.role in ['admin', 'director', 'project_manager'] %} + + QR Code + + {% endif %} {% if current_user.role in ['admin', 'director'] %} Edit diff --git a/app/templates/facility_qr/view.html b/app/templates/facility_qr/view.html new file mode 100644 index 0000000..3e38d8b --- /dev/null +++ b/app/templates/facility_qr/view.html @@ -0,0 +1,180 @@ + + + + + + + {{ facility.name }} — Facility Status + + + + + +
+ +
+ +
+
{{ facility.name }}
+
+ {% if contract %}{{ contract.name }}{% endif %} + {% if contract and facility.address %} · {% endif %} + {{ facility.address or '' }} +
+
+
+ + {# ── Summary stat tiles (90 days) ── #} +
+
+
+
{% if avg_90 is not none %}{{ '%.1f'|format(avg_90) }}%{% else %}—{% endif %}
+
Avg Score
90 days
+
+
+
+
+
{{ total_90 }}
+
Inspections
90 days
+
+
+
+
+
{{ open_total }}
+
Open
Issues
+
+
+
+
+
{{ resolved_90 }}
+
Resolved
90 days
+
+
+
+ + {# ── Score trend ── #} +
+
+
Score trend — 30 days vs prior 30
+ {% if trend_delta is not none %} + {% if trend_delta > 0.5 %} +
+ Improving + (+{{ '%.1f'|format(trend_delta) }} pts, {{ '%.1f'|format(avg_prior) }}% → {{ '%.1f'|format(avg_cur) }}%) +
+ {% elif trend_delta < -0.5 %} +
+ Declining + ({{ '%.1f'|format(trend_delta) }} pts, {{ '%.1f'|format(avg_prior) }}% → {{ '%.1f'|format(avg_cur) }}%) +
+ {% else %} +
+ Steady ({{ '%.1f'|format(avg_cur) }}%) +
+ {% endif %} + {% else %} +
Not enough data yet
+ {% endif %} +
+
+ + {# ── Open issues by severity + SLA state ── #} +
+
+ Open Issues +
+
+ {% if open_total or pending_verification %} +
+ {% for sev in severity_order %} + {% if severity_counts[sev] %} + + {{ severity_counts[sev] }} {{ sev }} + + {% endif %} + {% endfor %} + {% if pending_verification %} + {{ pending_verification }} pending verification + {% endif %} +
+ {% if sla_breached %} +
+ {{ sla_breached }} issue{{ 's' if sla_breached != 1 }} past the response-time target +
+ {% endif %} + {% if sla_at_risk %} +
+ {{ sla_at_risk }} issue{{ 's' if sla_at_risk != 1 }} approaching the response-time target +
+ {% endif %} + {% if not sla_breached and not sla_at_risk and open_total %} +
All open issues are within response-time targets.
+ {% endif %} + {% else %} +
No open issues right now.
+ {% endif %} +
+
+ + {# ── Recent inspections ── #} +
+
+ Recent Inspections +
+ {% if recent %} +
+ + + + + + + + + + {% for ins in recent %} + + + + + + {% endfor %} + +
DateChecklistScore
{{ ins.inspection_date.strftime('%b %d, %Y') }}{{ ins.template.name if ins.template else '—' }} + {% if ins.overall_score is not none %}{{ '%.1f'|format(ins.overall_score) }}%{% else %}—{% endif %} +
+
+ {% else %} +
No completed inspections yet.
+ {% endif %} +
+ + {% if can_view_full %} +
+ Open Full Facility View + + {% endif %} + +

+ {% if last_date %}Last inspected {{ last_date.strftime('%b %d, %Y') }} · {% endif %} + Snapshot generated {{ generated_at.strftime('%b %d, %Y %I:%M %p') }} ET +

+

Janitorial QC — facility quality snapshot

+
+ + diff --git a/app/utils/qr.py b/app/utils/qr.py new file mode 100644 index 0000000..5f14fcf --- /dev/null +++ b/app/utils/qr.py @@ -0,0 +1,24 @@ +""" +app/utils/qr.py +--------------- +Generic QR-code SVG helper (phase38). + +Mirrors qr_svg() in app/utils/mfa.py (which is TOTP-specific and mirrored in +control/mfa.py — do not touch those; this is the general-purpose variant for +facility QR codes and any future QR needs). +""" + +import io +import qrcode +import qrcode.image.svg + + +def qr_svg(data: str) -> str: + """Return an inline SVG string encoding `data` (no Pillow needed). + + The SVG carries its own width/height attrs; size it via CSS on the + containing element (svg { width:…; height:…; }) when embedding. + """ + buf = io.BytesIO() + qrcode.make(data, image_factory=qrcode.image.svg.SvgPathImage).save(buf) + return buf.getvalue().decode('utf-8') diff --git a/migrations/versions/phase38_facility_qr.py b/migrations/versions/phase38_facility_qr.py new file mode 100644 index 0000000..0961fee --- /dev/null +++ b/migrations/versions/phase38_facility_qr.py @@ -0,0 +1,58 @@ +"""phase38 — facility QR tokens + +Adds facilities.qr_token VARCHAR(64) NULL + unique index. The token backs the +public, login-less facility QR scan page (GET /f/) which shows recent +inspection scores, open-issue counts, and a 30-day score trend for that +facility — counts + scores only, no free text, no names, no photos. + +NULL for all existing rows; Facility.ensure_qr_token() generates lazily when +staff first open the QR card / bulk sheet. + +Idempotent: INFORMATION_SCHEMA column + index existence checks — safe to +re-run across every tenant DB (CLAUDE.md rule 14). Revision id kept ≤ 32 +chars (alembic_version.version_num is VARCHAR(32)). +""" + +import sqlalchemy as sa +from alembic import op + +revision = 'phase38_facility_qr' +down_revision = 'phase37_contract_recipients' +branch_labels = None +depends_on = None + + +def _column_exists(bind, table: str, column: str) -> bool: + result = bind.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}) + return result.scalar() > 0 + + +def _index_exists(bind, table: str, index: str) -> bool: + result = bind.execute(sa.text( + "SELECT COUNT(*) FROM information_schema.statistics " + "WHERE table_schema = DATABASE() AND table_name = :t AND index_name = :i" + ), {'t': table, 'i': index}) + return result.scalar() > 0 + + +def upgrade(): + bind = op.get_bind() + if not _column_exists(bind, 'facilities', 'qr_token'): + op.execute(sa.text( + "ALTER TABLE facilities ADD COLUMN qr_token VARCHAR(64) NULL" + )) + if not _index_exists(bind, 'facilities', 'uq_facilities_qr_token'): + op.execute(sa.text( + "CREATE UNIQUE INDEX uq_facilities_qr_token ON facilities (qr_token)" + )) + + +def downgrade(): + bind = op.get_bind() + if _index_exists(bind, 'facilities', 'uq_facilities_qr_token'): + op.execute(sa.text('DROP INDEX uq_facilities_qr_token ON facilities')) + if _column_exists(bind, 'facilities', 'qr_token'): + op.execute(sa.text('ALTER TABLE facilities DROP COLUMN qr_token'))