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
+79 -2
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
@@ -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))
+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,
)