""" 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. `GET /f/area/` (phase42) — the same page scoped to a single area, so a code posted inside one restroom reports on that restroom. Metrics are scoped by `Inspection.area_id` / `Issue.area_id`. Both pages show: * 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, flash) 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.forms import PublicIssueReportForm from app.utils.notifications import notify_by_matrix 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') #: Maximum number of photos an occupant may attach to a public report. MAX_REPORT_PHOTOS = 5 def _save_report_photos(file_list): """Save up to MAX_REPORT_PHOTOS uploaded photos from a public report. Returns (photo_path, extra_paths) where photo_path is the primary evidence photo (or None) and extra_paths is a list of the remaining paths (or None). Splitting this way mirrors the Issue photo model: the first photo lives in `photo_path`, the rest in `mobile_photo_paths` so they all render together under "Photo Evidence" on the web (rule 44 — never `result_photos`). Writes go through `_save_photo`, which validates magic bytes and routes to the active storage backend (see app/utils/storage.py). """ from app.routes.inspections import _save_photo saved = [] for f in (file_list or [])[:MAX_REPORT_PHOTOS]: path = _save_photo(f, subfolder='issue_photos') if path: saved.append(path) photo_path = saved[0] if saved else None extra_paths = saved[1:] if len(saved) > 1 else None return photo_path, extra_paths def _facility_by_token_or_404(token): """Resolve an ACTIVE facility from its QR token, else 404.""" if not token: abort(404) facility = Facility.query.filter_by(qr_token=token).first() if facility is None or not facility.active: abort(404) return facility def _area_by_token_or_404(token): """Resolve an area (and its ACTIVE facility) from the area's QR token.""" if not token: abort(404) area = Area.query.filter_by(qr_token=token).first() if area is None: abort(404) facility = db.session.get(Facility, area.facility_id) if facility is None or not facility.active: abort(404) return area, facility def _build_report_description(form, prefix): """Fold optional reporter identity + location into the issue description. The public reporter is not a User, so `reported_by` stays NULL and this is the only place their name/contact is recorded. """ parts = [prefix] if form.area_label.data: parts.append(f'Location: {form.area_label.data.strip()}') reporter_bits = [b for b in (form.reporter_name.data, form.reporter_contact.data) if b] if reporter_bits: parts.append('Reporter: ' + ' — '.join(b.strip() for b in reporter_bits)) parts.append('') parts.append(form.description.data.strip()) return '\n'.join(parts) 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.is_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 def _build_snapshot(facility, area=None): """Assemble the public snapshot for a facility, or for one area of it. When `area` is given every metric is scoped to that area via `Inspection.area_id` / `Issue.area_id`; otherwise the facility-wide math is used, unchanged from phase38. Returns the template context (minus `token`). """ now = now_eastern() d30 = now - timedelta(days=30) d60 = now - timedelta(days=60) d90 = now - timedelta(days=90) if area is not None: insp_scope = (Inspection.area_id == area.id,) issue_q = Issue.query.filter(Issue.area_id == area.id) else: insp_scope = (Inspection.facility_id == facility.id,) issue_q = (Issue.query .outerjoin(Area, Issue.area_id == Area.id) .filter(or_(Issue.facility_id == facility.id, Area.facility_id == facility.id))) completed = Inspection.query.filter( *insp_scope, 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( *insp_scope, 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( *insp_scope, 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) ───────── 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() return dict( facility = facility, area = area, 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('/') @limiter.limit('60 per hour') def scan(token): facility = _facility_by_token_or_404(token) 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, form = PublicIssueReportForm(), **_build_snapshot(facility), ) @bp.route('/area/') @limiter.limit('60 per hour') def area_scan(token): """Public snapshot for a single area (phase42).""" area, facility = _area_by_token_or_404(token) logger.info('AREA QR SCAN | area_id=%s | facility_id=%s | authenticated=%s', area.id, facility.id, current_user.is_authenticated) return render_template( 'facility_qr/area.html', token = token, form = PublicIssueReportForm(), **_build_snapshot(facility, area=area), ) @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. phase42: accepts up to 5 photos, an optional location label, and optional reporter identity, on top of the description + severity taken previously. """ facility = _facility_by_token_or_404(token) form = PublicIssueReportForm() # Honeypot — bots fill this field, humans leave it blank if form.website.data: logger.warning('FACILITY QR REPORT | honeypot triggered | facility_id=%s', facility.id) return redirect(url_for('facility_qr.scan', token=token) + '?reported=1') if not form.validate_on_submit(): # Re-render with validation errors and the snapshot intact. return render_template( 'facility_qr/view.html', token = token, form = form, **_build_snapshot(facility), ), 400 severity = form.severity.data or 'medium' if severity not in ('low', 'medium', 'high'): severity = 'medium' photo_path, extra_photos = _save_report_photos(form.photos.data) description = _build_report_description(form, '[Reported via facility QR code]') issue = Issue( facility_id = facility.id, area_id = None, severity = severity, description = description, photo_path = photo_path, mobile_photo_paths = extra_photos, status = 'open', reported_by = None, # anonymous public submission ) db.session.add(issue) db.session.commit() _photo_count = (1 if photo_path else 0) + (len(extra_photos) if extra_photos else 0) logger.info('FACILITY QR REPORT | facility_id=%s issue_id=%s severity=%s photos=%s', facility.id, issue.id, severity, _photo_count) # Notify staff via the notification matrix (same event as Issues → Create). # NOTE: title/body are REQUIRED positional args. The phase38 call omitted # them, so every public QR report raised TypeError into the except below and # nobody was ever notified — see MT3_DEPLOY.md §1. _snippet = form.description.data.strip() notify_by_matrix( event_type = 'issue_created', title = f'New Issue #{issue.id} at {facility.name} (QR report)', body = ( f'A problem was reported at {facility.name} via the facility QR code. ' f'Description: {_snippet[:120]}{"…" if len(_snippet) > 120 else ""}' ), link = url_for('issues.view', issue_id=issue.id), issue_id = issue.id, facility_id = facility.id, ) db.session.commit() return redirect(url_for('facility_qr.scan', token=token) + '?reported=1') @bp.route('/area//report', methods=['POST']) @limiter.limit('5 per hour') def area_report(token): """Public occupant issue report submitted from an area QR page (phase42). The area is known from the token, so `area_id` is set directly — staff see exactly which room the report came from without the occupant describing it. """ area, facility = _area_by_token_or_404(token) form = PublicIssueReportForm() if form.website.data: logger.warning('AREA QR REPORT | honeypot triggered | area_id=%s', area.id) return redirect(url_for('facility_qr.area_scan', token=token) + '?reported=1') if not form.validate_on_submit(): return render_template( 'facility_qr/area.html', token = token, form = form, **_build_snapshot(facility, area=area), ), 400 severity = form.severity.data or 'medium' if severity not in ('low', 'medium', 'high'): severity = 'medium' photo_path, extra_photos = _save_report_photos(form.photos.data) description = _build_report_description( form, f'[Reported via area QR code — {area.name}]') issue = Issue( facility_id = facility.id, area_id = area.id, severity = severity, description = description, photo_path = photo_path, mobile_photo_paths = extra_photos, status = 'open', reported_by = None, # anonymous public submission ) db.session.add(issue) db.session.commit() _photo_count = (1 if photo_path else 0) + (len(extra_photos) if extra_photos else 0) logger.info('AREA QR REPORT | area_id=%s facility_id=%s issue_id=%s severity=%s photos=%s', area.id, facility.id, issue.id, severity, _photo_count) _snippet = form.description.data.strip() notify_by_matrix( event_type = 'issue_created', title = f'New Issue #{issue.id} at {facility.name} — {area.name} (QR report)', body = ( f'A problem was reported in {area.name} at {facility.name} via the area ' f'QR code. Description: {_snippet[:120]}{"…" if len(_snippet) > 120 else ""}' ), link = url_for('issues.view', issue_id=issue.id), issue_id = issue.id, facility_id = facility.id, ) db.session.commit() return redirect(url_for('facility_qr.area_scan', token=token) + '?reported=1')