Files
JQC_multi_tenant/app/routes/facility_qr.py
T

406 lines
15 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.
`GET /f/area/<token>` (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.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
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('/<token>')
@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/<token>')
@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('/<token>/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/<token>/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')