157 lines
6.0 KiB
Python
157 lines
6.0 KiB
Python
"""
|
|
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,
|
|
)
|