Jul 7 - Implement QR codes for facility
This commit is contained in:
@@ -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/`.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}>'
|
||||
|
||||
|
||||
@@ -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))
|
||||
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))
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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'))
|
||||
Reference in New Issue
Block a user