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
+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,
)