Jul 7 - Implement QR codes for facility

This commit is contained in:
2026-07-07 21:05:07 -04:00
parent 8b5a4758e5
commit faab9fd008
12 changed files with 684 additions and 7 deletions
+21 -5
View File
@@ -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/<token>` (blueprint `facility_qr`, rule 91). NULL until first requested — `Facility.ensure_qr_token()` generates it lazily when staff open the QR card (`/facilities/<id>/qr`) or bulk print sheet (`/facilities/qr-sheet`). Regenerating (`POST /facilities/<id>/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 /<id>/qr` printable card + `GET /qr-sheet` bulk print (`@project_manager_required`), `POST /<id>/qr/regenerate` (`@supervisor_required`) |
| `facility_qr` | `/f` | phase38 — **public, login-less** facility QR scan page: `GET /<token>` 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 /<id>/recipients`, `POST /<id>/recipients/add`, `POST /recipients/<rid>/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/<token>` — 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/<token>` 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/<token>` 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/<id>/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/`.
+2
View File
@@ -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)
+11
View File
@@ -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/<token>).
# 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'<Facility {self.name}>'
+78 -1
View File
@@ -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
@@ -238,3 +238,80 @@ def delete_area(area_id):
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))
# ── 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('/<int:facility_id>/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('/<int:facility_id>/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))
+156
View File
@@ -0,0 +1,156 @@
"""
app/routes/facility_qr.py
-------------------------
Public facility QR scan page (phase38).
`GET /f/<token>` 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('/<token>')
@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,
)
+5
View File
@@ -8,6 +8,11 @@
<h2><i class="bi bi-building"></i> Facilities</h2>
</div>
<div class="col-md-6 text-end">
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
<a href="{{ url_for('facilities.qr_sheet') }}" class="btn btn-outline-secondary">
<i class="bi bi-qr-code"></i> Print QR Codes
</a>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('facilities.create_facility') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add Facility
+75
View File
@@ -0,0 +1,75 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>QR Code — {{ facility.name }}</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; }
.qr-card { max-width:420px; margin:2rem auto; background:#fff; border-radius:.75rem;
box-shadow:0 1px 3px rgba(0,0,0,.12); padding:2rem; text-align:center; }
.qr-box svg { width:260px; height:260px; }
.scan-url { word-break:break-all; font-size:.72rem; color:#94a3b8; }
@media print {
body { background:#fff; }
.no-print { display:none !important; }
.qr-card { box-shadow:none; margin:0 auto; }
}
</style>
</head>
<body>
<div class="text-center mt-3 no-print">
<a href="{{ url_for('facilities.view_facility', facility_id=facility.id) }}"
class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Back to Facility
</a>
<button onclick="window.print()" class="btn btn-primary btn-sm">
<i class="bi bi-printer"></i> Print
</button>
<a href="{{ url_for('facilities.qr_sheet') }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-grid-3x3-gap"></i> Print All Facilities
</a>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ 'success' if category == 'success' else 'warning' }} mx-auto mt-3 no-print" style="max-width:420px;">
{{ message }}
</div>
{% endfor %}
{% endwith %}
<div class="qr-card">
<h4 class="mb-0">{{ facility.name }}</h4>
{% if facility.project %}<div class="text-muted small">{{ facility.project.name }}</div>{% endif %}
{% if facility.address %}<div class="text-muted small mb-2">{{ facility.address }}</div>{% endif %}
<div class="qr-box my-3">{{ svg | safe }}</div>
<div class="fw-semibold mb-1">
<i class="bi bi-phone"></i> Scan for the latest inspection results
</div>
<div class="text-muted small mb-2">
Recent scores, open issues, and quality trend for this facility.
</div>
<div class="scan-url">{{ scan_url }}</div>
</div>
{% if current_user.role in ['admin', 'director'] %}
<div class="text-center mb-4 no-print">
<form method="POST" action="{{ url_for('facilities.regenerate_qr', facility_id=facility.id) }}"
onsubmit="return confirm('Regenerate this QR code? Every previously printed poster for this facility will stop working.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-outline-danger btn-sm">
<i class="bi bi-arrow-repeat"></i> Regenerate QR Code
</button>
<div class="form-text">Use this if a printed poster leaked or was posted somewhere it shouldn't be.</div>
</form>
</div>
{% endif %}
</body>
</html>
+67
View File
@@ -0,0 +1,67 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Facility QR Codes — Print Sheet</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; }
.sheet { max-width:900px; margin:1.5rem auto; padding:0 1rem; }
.qr-cell { background:#fff; border:1px solid #e2e8f0; border-radius:.5rem;
padding:1.25rem; text-align:center; height:100%;
break-inside:avoid; page-break-inside:avoid; }
.qr-cell svg { width:180px; height:180px; }
.qr-cell .fac { font-weight:600; margin-top:.5rem; }
.qr-cell .sub { font-size:.75rem; color:#64748b; }
@media print {
body { background:#fff; }
.no-print { display:none !important; }
.sheet { max-width:none; margin:0; padding:0; }
.qr-cell { border:1px dashed #cbd5e1; border-radius:0; }
}
</style>
</head>
<body>
<div class="sheet">
<div class="d-flex justify-content-between align-items-center mb-3 no-print">
<h4 class="mb-0"><i class="bi bi-qr-code"></i> Facility QR Codes ({{ cards|length }})</h4>
<div class="d-flex gap-2">
<a href="{{ url_for('facilities.list_facilities') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Back to Facilities
</a>
<button onclick="window.print()" class="btn btn-primary btn-sm">
<i class="bi bi-printer"></i> Print All
</button>
</div>
</div>
<p class="text-muted small no-print">
Cut along the dashed borders and post each code at its facility.
Scanning shows recent inspection scores, open-issue counts, and the quality trend.
</p>
<div class="row g-3">
{% for c in cards %}
<div class="col-12 col-sm-6 col-md-4">
<div class="qr-cell">
{{ c.svg | safe }}
<div class="fac">{{ c.facility.name }}</div>
<div class="sub">
{% if c.facility.project %}{{ c.facility.project.name }}<br>{% endif %}
Scan for the latest inspection results
</div>
</div>
</div>
{% endfor %}
</div>
{% if not cards %}
<div class="alert alert-info">No active facilities to print.</div>
{% endif %}
</div>
</body>
</html>
+6
View File
@@ -17,6 +17,12 @@
<i class="bi bi-graph-up-arrow"></i> Scorecard
</a>
{% endif %}
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
<a href="{{ url_for('facilities.qr_card', facility_id=facility.id) }}"
class="btn btn-outline-secondary">
<i class="bi bi-qr-code"></i> QR Code
</a>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('facilities.edit_facility', facility_id=facility.id) }}" class="btn btn-outline-primary">
<i class="bi bi-pencil"></i> Edit
+180
View File
@@ -0,0 +1,180 @@
<!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>{{ facility.name }} — Facility Status</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; }
.fq-wrap { max-width:640px; margin:2rem auto; padding:0 1rem; }
.stat-tile { background:#fff; border-radius:.5rem; padding:.9rem .5rem; text-align:center;
box-shadow:0 1px 2px rgba(0,0,0,.06); height:100%; }
.stat-tile .val { font-size:1.6rem; font-weight:700; line-height:1.2; }
.stat-tile .lbl { font-size:.72rem; color:#64748b; text-transform:uppercase;
letter-spacing:.03em; margin-top:.15rem; }
.sev-critical{background:#dc2626}.sev-high{background:#ea580c}
.sev-medium{background:#d97706}.sev-low{background:#64748b}
.trend-up { color:#15803d; }
.trend-down { color:#dc2626; }
.trend-flat { color:#64748b; }
@media (max-width:576px){ .fq-wrap{ margin:1rem auto; } }
</style>
</head>
<body>
<div class="fq-wrap">
<div class="d-flex align-items-center gap-2 mb-3">
<i class="bi bi-building fs-3 text-primary"></i>
<div>
<div class="fw-bold">{{ facility.name }}</div>
<div class="text-muted small">
{% if contract %}{{ contract.name }}{% endif %}
{% if contract and facility.address %} · {% endif %}
{{ facility.address or '' }}
</div>
</div>
</div>
{# ── Summary stat tiles (90 days) ── #}
<div class="row g-2 mb-3">
<div class="col-3">
<div class="stat-tile">
<div class="val">{% if avg_90 is not none %}{{ '%.1f'|format(avg_90) }}%{% else %}—{% endif %}</div>
<div class="lbl">Avg Score<br>90 days</div>
</div>
</div>
<div class="col-3">
<div class="stat-tile">
<div class="val">{{ total_90 }}</div>
<div class="lbl">Inspections<br>90 days</div>
</div>
</div>
<div class="col-3">
<div class="stat-tile">
<div class="val">{{ open_total }}</div>
<div class="lbl">Open<br>Issues</div>
</div>
</div>
<div class="col-3">
<div class="stat-tile">
<div class="val">{{ resolved_90 }}</div>
<div class="lbl">Resolved<br>90 days</div>
</div>
</div>
</div>
{# ── Score trend ── #}
<div class="card shadow-sm mb-3">
<div class="card-body py-2 d-flex align-items-center justify-content-between">
<div class="text-muted small text-uppercase">Score trend — 30 days vs prior 30</div>
{% if trend_delta is not none %}
{% if trend_delta > 0.5 %}
<div class="trend-up fw-semibold">
<i class="bi bi-arrow-up-right"></i> Improving
(+{{ '%.1f'|format(trend_delta) }} pts, {{ '%.1f'|format(avg_prior) }}% → {{ '%.1f'|format(avg_cur) }}%)
</div>
{% elif trend_delta < -0.5 %}
<div class="trend-down fw-semibold">
<i class="bi bi-arrow-down-right"></i> Declining
({{ '%.1f'|format(trend_delta) }} pts, {{ '%.1f'|format(avg_prior) }}% → {{ '%.1f'|format(avg_cur) }}%)
</div>
{% else %}
<div class="trend-flat fw-semibold">
<i class="bi bi-arrow-right"></i> Steady ({{ '%.1f'|format(avg_cur) }}%)
</div>
{% endif %}
{% else %}
<div class="text-muted small">Not enough data yet</div>
{% endif %}
</div>
</div>
{# ── Open issues by severity + SLA state ── #}
<div class="card shadow-sm mb-3">
<div class="card-header bg-white fw-semibold py-2">
<i class="bi bi-exclamation-triangle"></i> Open Issues
</div>
<div class="card-body py-3">
{% if open_total or pending_verification %}
<div class="d-flex flex-wrap gap-2 mb-2">
{% for sev in severity_order %}
{% if severity_counts[sev] %}
<span class="badge sev-{{ sev }} text-white">
{{ severity_counts[sev] }} {{ sev }}
</span>
{% endif %}
{% endfor %}
{% if pending_verification %}
<span class="badge bg-info text-dark">{{ pending_verification }} pending verification</span>
{% endif %}
</div>
{% if sla_breached %}
<div class="small text-danger fw-semibold">
<i class="bi bi-alarm"></i> {{ sla_breached }} issue{{ 's' if sla_breached != 1 }} past the response-time target
</div>
{% endif %}
{% if sla_at_risk %}
<div class="small text-warning-emphasis fw-semibold">
<i class="bi bi-hourglass-split"></i> {{ sla_at_risk }} issue{{ 's' if sla_at_risk != 1 }} approaching the response-time target
</div>
{% endif %}
{% if not sla_breached and not sla_at_risk and open_total %}
<div class="small text-muted">All open issues are within response-time targets.</div>
{% endif %}
{% else %}
<div class="text-muted small"><i class="bi bi-check-circle text-success"></i> No open issues right now.</div>
{% endif %}
</div>
</div>
{# ── Recent inspections ── #}
<div class="card shadow-sm mb-3">
<div class="card-header bg-white fw-semibold py-2">
<i class="bi bi-clipboard-check"></i> Recent Inspections
</div>
{% if recent %}
<div class="table-responsive">
<table class="table table-sm mb-0 align-middle">
<thead class="table-light">
<tr>
<th class="ps-3">Date</th>
<th>Checklist</th>
<th class="text-end pe-3">Score</th>
</tr>
</thead>
<tbody>
{% for ins in recent %}
<tr>
<td class="ps-3 text-nowrap">{{ ins.inspection_date.strftime('%b %d, %Y') }}</td>
<td class="text-muted small">{{ ins.template.name if ins.template else '—' }}</td>
<td class="text-end pe-3 fw-semibold">
{% if ins.overall_score is not none %}{{ '%.1f'|format(ins.overall_score) }}%{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card-body py-3 text-muted small">No completed inspections yet.</div>
{% endif %}
</div>
{% if can_view_full %}
<a href="{{ url_for('facilities.view_facility', facility_id=facility.id) }}"
class="btn btn-primary w-100 mb-3">
<i class="bi bi-box-arrow-in-right me-1"></i> Open Full Facility View
</a>
{% endif %}
<p class="text-center text-muted small mt-2 mb-1">
{% if last_date %}Last inspected {{ last_date.strftime('%b %d, %Y') }} · {% endif %}
Snapshot generated {{ generated_at.strftime('%b %d, %Y %I:%M %p') }} ET
</p>
<p class="text-center text-muted small">Janitorial QC — facility quality snapshot</p>
</div>
</body>
</html>
+24
View File
@@ -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')
@@ -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/<token>) 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'))