""" app/routes/public.py -------------------- Login-free, token-addressed facility pages reached by scanning a facility's QR code. The QR encodes /f/ (an unguessable token, so the pages cannot be enumerated by facility id). Routes ------ GET /f/ Occupant-friendly facility summary (no login). POST /f//report Occupant "report a problem" → creates an open Issue. Design notes ------------ - Occupant-friendly: shows a quality rating, last-inspected date, and open-issue COUNT only — never issue descriptions, inspector names, or internal scores. - Inactive facilities 404 (a decommissioned QR reveals nothing). - The report form is rate-limited and honeypot-guarded against bots, and reuses the normal issue-creation notification path so staff/customers are alerted. """ import logging from datetime import timedelta from flask import Blueprint, render_template, redirect, url_for, flash, request, abort from sqlalchemy import func 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.time_utils import now_eastern from app.utils.notifications import notify_by_matrix logger = logging.getLogger(__name__) bp = Blueprint('public', __name__, url_prefix='/f') #: 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`). """ 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 #: Window within which an identical public report is treated as a duplicate. DUPLICATE_REPORT_WINDOW_SECONDS = 60 def _recent_duplicate_report(facility_id, area_id, description): """Return True if an identical public report was just filed. Belt-and-suspenders against duplicate submissions (double-taps, JS-disabled clients, retries): if a public issue (reported_by IS NULL) with the same facility/area and identical description was created within the last DUPLICATE_REPORT_WINDOW_SECONDS, treat this one as a duplicate and skip it. """ cutoff = now_eastern() - timedelta(seconds=DUPLICATE_REPORT_WINDOW_SECONDS) q = Issue.query.filter( Issue.reported_by.is_(None), Issue.reported_at >= cutoff, Issue.description == description, ) if area_id is not None: q = q.filter(Issue.area_id == area_id) else: q = q.filter(Issue.facility_id == facility_id, Issue.area_id.is_(None)) return db.session.query(q.exists()).scalar() def _facility_by_token_or_404(token: str) -> Facility: """Resolve an ACTIVE facility from its public token, else 404.""" if not token: abort(404) facility = Facility.query.filter_by(public_token=token).first() if facility is None or not facility.active: abort(404) return facility def _rating_label(score): """Map a 0–100 score to an occupant-friendly label + Bootstrap colour.""" if score is None: return ('Not yet rated', 'secondary') if score >= 90: return ('Excellent', 'success') if score >= 80: return ('Good', 'success') if score >= 70: return ('Fair', 'warning') return ('Needs attention', 'danger') def _build_summary(facility: Facility) -> dict: """Assemble the occupant-facing summary for a facility. Occupant-safe (rule 74): aggregate rating, counts, a score trend, and recent inspection DATES only — no checklist/template names, no per-inspection scores, no issue descriptions, and no severity/SLA detail. """ fid = facility.id now = now_eastern() cutoff_90 = now - timedelta(days=90) cutoff_30 = now - timedelta(days=30) cutoff_60 = now - timedelta(days=60) # Most recent completed, scored inspection (for last-inspected date) last_insp = ( Inspection.query .filter(Inspection.facility_id == fid, Inspection.status == 'completed', Inspection.overall_score.isnot(None)) .order_by(Inspection.inspection_date.desc()) .first() ) def _avg_between(start, end=None): q = (db.session.query(func.avg(Inspection.overall_score)) .filter(Inspection.facility_id == fid, Inspection.status == 'completed', Inspection.overall_score.isnot(None), Inspection.inspection_date >= start)) if end is not None: q = q.filter(Inspection.inspection_date < end) return q.scalar() # Average score over the last 90 days (fallback: all-time) for the rating avg_90 = _avg_between(cutoff_90) if avg_90 is None: avg_90 = ( db.session.query(func.avg(Inspection.overall_score)) .filter(Inspection.facility_id == fid, Inspection.status == 'completed', Inspection.overall_score.isnot(None)) .scalar() ) avg_score = round(float(avg_90), 1) if avg_90 is not None else None # Completed-inspection count over the last 90 days inspections_90 = ( Inspection.query .filter(Inspection.facility_id == fid, Inspection.status == 'completed', Inspection.inspection_date >= cutoff_90) .count() ) # Score trend: last 30 days vs the prior 30 days (aggregate only) avg_cur = _avg_between(cutoff_30) avg_prior = _avg_between(cutoff_60, cutoff_30) if avg_cur is not None and avg_prior is not None: trend_delta = round(float(avg_cur) - float(avg_prior), 1) else: trend_delta = None # Recent inspection DATES only (no checklist names, no scores) recent = ( Inspection.query .filter(Inspection.facility_id == fid, Inspection.status == 'completed') .order_by(Inspection.inspection_date.desc()) .limit(5) .all() ) recent_dates = [i.inspection_date for i in recent] # Open-issue COUNT (linked directly or via an area) — no details exposed open_issue_count = ( Issue.query .outerjoin(Area, Issue.area_id == Area.id) .filter(Issue.status.in_(['open', 'in_progress']), db.or_(Issue.facility_id == fid, Area.facility_id == fid)) .count() ) # Resolved-issue COUNT over the last 90 days — no details exposed resolved_90 = ( Issue.query .outerjoin(Area, Issue.area_id == Area.id) .filter(Issue.status == 'resolved', Issue.resolved_at.isnot(None), Issue.resolved_at >= cutoff_90, db.or_(Issue.facility_id == fid, Area.facility_id == fid)) .count() ) label, colour = _rating_label(avg_score) return { 'facility': facility, 'avg_score': avg_score, 'rating_label': label, 'rating_colour': colour, 'inspections_90': inspections_90, 'open_issue_count': open_issue_count, 'resolved_90': resolved_90, 'trend_delta': trend_delta, 'last_inspected': last_insp.inspection_date if last_insp else None, 'recent_dates': recent_dates, } def _area_by_token_or_404(token): """Resolve an area (and its ACTIVE facility) from the area's public token.""" if not token: abort(404) area = Area.query.filter_by(public_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_area_summary(area, facility) -> dict: """Assemble the occupant-facing summary for a single area. Occupant-safe (rule 74): same aggregate-only shape as the facility page, but every metric is scoped to this area's inspections/issues. """ aid = area.id now = now_eastern() cutoff_90 = now - timedelta(days=90) cutoff_30 = now - timedelta(days=30) cutoff_60 = now - timedelta(days=60) last_insp = ( Inspection.query .filter(Inspection.area_id == aid, Inspection.status == 'completed', Inspection.overall_score.isnot(None)) .order_by(Inspection.inspection_date.desc()) .first() ) def _avg_between(start, end=None): q = (db.session.query(func.avg(Inspection.overall_score)) .filter(Inspection.area_id == aid, Inspection.status == 'completed', Inspection.overall_score.isnot(None), Inspection.inspection_date >= start)) if end is not None: q = q.filter(Inspection.inspection_date < end) return q.scalar() avg_90 = _avg_between(cutoff_90) if avg_90 is None: avg_90 = ( db.session.query(func.avg(Inspection.overall_score)) .filter(Inspection.area_id == aid, Inspection.status == 'completed', Inspection.overall_score.isnot(None)) .scalar() ) avg_score = round(float(avg_90), 1) if avg_90 is not None else None inspections_90 = ( Inspection.query .filter(Inspection.area_id == aid, Inspection.status == 'completed', Inspection.inspection_date >= cutoff_90) .count() ) avg_cur = _avg_between(cutoff_30) avg_prior = _avg_between(cutoff_60, cutoff_30) if avg_cur is not None and avg_prior is not None: trend_delta = round(float(avg_cur) - float(avg_prior), 1) else: trend_delta = None recent = ( Inspection.query .filter(Inspection.area_id == aid, Inspection.status == 'completed') .order_by(Inspection.inspection_date.desc()) .limit(5) .all() ) recent_dates = [i.inspection_date for i in recent] open_issue_count = ( Issue.query .filter(Issue.status.in_(['open', 'in_progress']), Issue.area_id == aid) .count() ) resolved_90 = ( Issue.query .filter(Issue.status == 'resolved', Issue.resolved_at.isnot(None), Issue.resolved_at >= cutoff_90, Issue.area_id == aid) .count() ) label, colour = _rating_label(avg_score) return { 'facility': facility, 'area': area, 'avg_score': avg_score, 'rating_label': label, 'rating_colour': colour, 'inspections_90': inspections_90, 'open_issue_count': open_issue_count, 'resolved_90': resolved_90, 'trend_delta': trend_delta, 'last_inspected': last_insp.inspection_date if last_insp else None, 'recent_dates': recent_dates, } @bp.route('/', methods=['GET']) def facility_summary(token): facility = _facility_by_token_or_404(token) summary = _build_summary(facility) form = PublicIssueReportForm() return render_template('public/facility.html', form=form, token=token, **summary) @bp.route('/area/', methods=['GET']) def area_summary(token): area, facility = _area_by_token_or_404(token) summary = _build_area_summary(area, facility) form = PublicIssueReportForm() return render_template('public/area.html', form=form, token=token, **summary) @bp.route('//report', methods=['POST']) @limiter.limit('5 per hour; 20 per day') def report_problem(token): facility = _facility_by_token_or_404(token) form = PublicIssueReportForm() # Honeypot: silently accept-and-drop obvious bot submissions. if form.website.data: logger.info('PUBLIC REPORT | honeypot tripped | facility_id=%s | ip=%s', facility.id, request.remote_addr) flash('Thank you — your report has been received.', 'success') return redirect(url_for('public.facility_summary', token=token)) if not form.validate_on_submit(): # Re-render the page with validation errors and the summary intact. summary = _build_summary(facility) return render_template('public/facility.html', form=form, token=token, **summary), 400 # Fold optional reporter identity + location into the description; the # public reporter is not a User, so reported_by stays NULL. parts = ['[Reported via facility QR code]'] 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()) description = '\n'.join(parts) # Server-side idempotency: silently accept an identical repeat as success # without creating a second issue (or saving its photos). if _recent_duplicate_report(facility.id, None, description): logger.info('PUBLIC REPORT | duplicate suppressed | facility_id=%s | ip=%s', facility.id, request.remote_addr) flash('Thank you — your report has been received and the team has been notified.', 'success') return redirect(url_for('public.facility_summary', token=token)) # Save up to 5 optional photos through the shared, magic-byte-validated # saver. First → photo_path, the rest → mobile_photo_paths. photo_path, extra_photos = _save_report_photos(form.photos.data) issue = Issue( facility_id = facility.id, area_id = None, severity = 'medium', description = description, photo_path = photo_path, mobile_photo_paths = extra_photos, status = 'open', reported_at = now_eastern(), reported_by = None, ) 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('PUBLIC REPORT | issue_id=%s | facility_id=%s | ip=%s | photos=%s', issue.id, facility.id, request.remote_addr, _photo_count) # Reuse the standard issue-created routing (staff + facility customers). 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: {form.description.data.strip()[:120]}' f'{"…" if len(form.description.data.strip()) > 120 else ""}' ), link = url_for('issues.view', issue_id=issue.id), issue_id = issue.id, facility_id = facility.id, ) db.session.commit() flash('Thank you — your report has been received and the team has been notified.', 'success') return redirect(url_for('public.facility_summary', token=token)) @bp.route('/area//report', methods=['POST']) @limiter.limit('5 per hour; 20 per day') def area_report_problem(token): area, facility = _area_by_token_or_404(token) form = PublicIssueReportForm() # Honeypot: silently accept-and-drop obvious bot submissions. if form.website.data: logger.info('PUBLIC REPORT | honeypot tripped | area_id=%s | ip=%s', area.id, request.remote_addr) flash('Thank you — your report has been received.', 'success') return redirect(url_for('public.area_summary', token=token)) if not form.validate_on_submit(): summary = _build_area_summary(area, facility) return render_template('public/area.html', form=form, token=token, **summary), 400 # The area is known from the QR token, so we set area_id directly and note # the source. A public reporter is not a User, so reported_by stays NULL. parts = [f'[Reported via area QR code — {area.name}]'] 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()) description = '\n'.join(parts) # Server-side idempotency: silently accept an identical repeat as success # without creating a second issue (or saving its photos). if _recent_duplicate_report(facility.id, area.id, description): logger.info('PUBLIC REPORT | duplicate suppressed | area_id=%s | facility_id=%s | ip=%s', area.id, facility.id, request.remote_addr) flash('Thank you — your report has been received and the team has been notified.', 'success') return redirect(url_for('public.area_summary', token=token)) # Save up to 5 optional photos (first → photo_path, rest → mobile_photo_paths). photo_path, extra_photos = _save_report_photos(form.photos.data) issue = Issue( facility_id = facility.id, area_id = area.id, severity = 'medium', description = description, photo_path = photo_path, mobile_photo_paths = extra_photos, status = 'open', reported_at = now_eastern(), reported_by = None, ) 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('PUBLIC REPORT | issue_id=%s | area_id=%s | facility_id=%s | ip=%s | photos=%s', issue.id, area.id, facility.id, request.remote_addr, _photo_count) 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: {form.description.data.strip()[:120]}' f'{"…" if len(form.description.data.strip()) > 120 else ""}' ), link = url_for('issues.view', issue_id=issue.id), issue_id = issue.id, facility_id = facility.id, ) db.session.commit() flash('Thank you — your report has been received and the team has been notified.', 'success') return redirect(url_for('public.area_summary', token=token))