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