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 ### 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 areas: id, facility_id (FK), name, area_type
``` ```
**`area_type` choices:** `restroom`, `lobby`, `hallway`, `office`, `kitchen`, `storage`, `floor`, `outdoor`, `other` **`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 ### 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) | | `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) | | `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) | | `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 | | `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) | | `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 ## 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`. **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 → phase34_inspection_schedules
→ phase35_user_mfa → phase35_user_mfa
→ phase36_issue_work_orders → 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 ### 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. | | 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. | | 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). | | 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 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.** **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/`. 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 scheduled_reports # Phase 6 — Scheduled reports
from app.routes import inspection_schedules # phase34 — recurring inspections 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 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 support # Support chat + admin tickets
from app.routes import broadcast # Admin broadcast messages from app.routes import broadcast # Admin broadcast messages
from app.routes import devices # Admin device management 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(scheduled_reports.bp)
app.register_blueprint(inspection_schedules.bp) app.register_blueprint(inspection_schedules.bp)
app.register_blueprint(work_orders.bp) app.register_blueprint(work_orders.bp)
app.register_blueprint(facility_qr.bp)
app.register_blueprint(support.bp) app.register_blueprint(support.bp)
app.register_blueprint(broadcast.bp) app.register_blueprint(broadcast.bp)
app.register_blueprint(devices.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'), project_id = db.Column(db.Integer, db.ForeignKey('projects.id', ondelete='SET NULL'),
nullable=True, index=True) 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 # Relationships
areas = db.relationship('Area', backref='facility', lazy='dynamic') areas = db.relationship('Area', backref='facility', lazy='dynamic')
inspections = db.relationship('Inspection', 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): def __repr__(self):
return f'<Facility {self.name}>' return f'<Facility {self.name}>'
+79 -2
View File
@@ -5,7 +5,7 @@ from app import db
from app.models.facility import Facility, Area from app.models.facility import Facility, Area
from app.models.project import Project from app.models.project import Project
from app.utils.forms import FacilityForm, AreaForm 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.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
from app.utils.scope import get_customer_scope, get_inspector_scope from app.utils.scope import get_customer_scope, get_inspector_scope
from app.tenancy.gates import quota_soft_check 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) current_user.username, area_id_snap, area_name)
log_action(ACTION_DELETE, 'Area', area_id_snap, area_name) log_action(ACTION_DELETE, 'Area', area_id_snap, area_name)
flash(f'Area "{area_name}" deleted successfully.', 'success') flash(f'Area "{area_name}" deleted successfully.', 'success')
return redirect(url_for('facilities.view_facility', facility_id=facility_id)) 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> <h2><i class="bi bi-building"></i> Facilities</h2>
</div> </div>
<div class="col-md-6 text-end"> <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'] %} {% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('facilities.create_facility') }}" class="btn btn-primary"> <a href="{{ url_for('facilities.create_facility') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add Facility <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 <i class="bi bi-graph-up-arrow"></i> Scorecard
</a> </a>
{% endif %} {% 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'] %} {% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('facilities.edit_facility', facility_id=facility.id) }}" class="btn btn-outline-primary"> <a href="{{ url_for('facilities.edit_facility', facility_id=facility.id) }}" class="btn btn-outline-primary">
<i class="bi bi-pencil"></i> Edit <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'))