""" app/routes/facility_qr.py ------------------------- Public facility QR scan page (phase38). `GET /f/` — 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, redirect, request, url_for, 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('/') @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', token = token, 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, ) @bp.route('//report', methods=['POST']) @limiter.limit('5 per hour') def report(token): """Public occupant issue report submitted from the QR scan page. No login required — the unguessable QR token is the sole authorization. A honeypot field silently rejects bot submissions. Creates an Issue with reported_by=None so staff know it came from a public form. """ facility = Facility.query.filter_by(qr_token=token).first() if facility is None or not facility.active: abort(404) # Honeypot — bots fill this field, humans leave it blank if request.form.get('website', '').strip(): logger.warning('FACILITY QR REPORT | honeypot triggered | facility_id=%s', facility.id) return redirect(url_for('facility_qr.scan', token=token) + '?reported=1') description = request.form.get('description', '').strip() severity = request.form.get('severity', 'medium') if not description: return redirect(url_for('facility_qr.scan', token=token)) if severity not in ('low', 'medium', 'high'): severity = 'medium' issue = Issue( facility_id = facility.id, severity = severity, description = description, status = 'open', reported_by = None, # anonymous public submission ) db.session.add(issue) db.session.commit() logger.info('FACILITY QR REPORT | facility_id=%s issue_id=%s severity=%s', facility.id, issue.id, severity) # Notify staff via the notification matrix (same event as Issues → Create) try: from app.utils.notifications import notify_by_matrix notify_by_matrix('issue_created', issue_id=issue.id, facility_id=facility.id) except Exception as exc: logger.error('FACILITY QR REPORT | notify_failed | err=%s', exc) return redirect(url_for('facility_qr.scan', token=token) + '?reported=1')