diff --git a/app/models/facility.py b/app/models/facility.py index 27a2848..dca0eca 100644 --- a/app/models/facility.py +++ b/app/models/facility.py @@ -42,6 +42,18 @@ class Area(db.Model): name = db.Column(db.String(255), nullable=False) area_type = db.Column(db.String(50)) + # phase42: unguessable token behind the public area scan page (/f/area/). + # NULL until first requested — ensure_qr_token() generates it lazily, exactly + # like Facility.qr_token above. + qr_token = db.Column(db.String(64), unique=True, nullable=True) + + def ensure_qr_token(self): + """Generate the QR token on first use. Caller commits.""" + if not self.qr_token: + import secrets + self.qr_token = secrets.token_urlsafe(32) + return self.qr_token + # Relationships inspections = db.relationship('Inspection', backref='area', lazy='dynamic') issues = db.relationship('Issue', backref='area', lazy='dynamic') diff --git a/app/routes/facilities.py b/app/routes/facilities.py index 757dfdf..b5fbaf8 100644 --- a/app/routes/facilities.py +++ b/app/routes/facilities.py @@ -6,7 +6,8 @@ from app.models.facility import Facility, Area from app.models.project import Project from app.utils.forms import FacilityForm, AreaForm from app.utils.decorators import supervisor_required, admin_required, project_manager_required -from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE +from app.utils.audit import (log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, + ACTION_EXPORT) from app.utils.scope import get_customer_scope, get_inspector_scope from app.tenancy.gates import quota_soft_check @@ -244,20 +245,70 @@ def delete_area(area_id): def _qr_scan_url(facility): """Absolute public scan URL, built from the current host (rule 64 pattern).""" + facility.ensure_qr_token() return request.host_url.rstrip('/') + url_for('facility_qr.scan', token=facility.qr_token) +def _facility_for_qr_or_403(facility_id): + """Load a facility for a QR action, enforcing customer facility scope. + + Customers may only touch QR codes for facilities they are assigned to; all + other roles have unrestricted QR access. Replaces the previous + @project_manager_required gate so a customer can print (and rotate) the + codes posted in their own building. + """ + facility = db.session.get(Facility, facility_id) + if facility is None: + abort(404) + # QR management is not an inspector task (matches qr_print_all/qr_export_pdf). + if current_user.role == 'inspector': + abort(403) + if current_user.role == 'customer': + cids = get_customer_scope(current_user) or [] + if facility.id not in cids: + abort(403) + return facility + + +def _qr_png_bytes(url): + """Return PNG bytes for a QR code encoding *url*.""" + import io as _io + import qrcode + img = qrcode.make(url, box_size=10, border=2) + buf = _io.BytesIO() + img.save(buf, format='PNG') + return buf.getvalue() + + +@bp.route('//qr.png') +@login_required +def facility_qr_png(facility_id): + """Return the facility's QR code as a PNG image. + + The printable card renders inline SVG; this PNG endpoint exists for the + print-all grid and is the same image the PDF export embeds. + """ + facility = _facility_for_qr_or_403(facility_id) + + created = not facility.qr_token + url = _qr_scan_url(facility) + if created: + db.session.commit() + + from flask import Response + return Response(_qr_png_bytes(url), mimetype='image/png', headers={ + 'Cache-Control': 'private, max-age=3600', + }) + + @bp.route('//qr') @login_required -@project_manager_required def qr_card(facility_id): """Printable QR card for one facility. Generates the token on first use.""" from app.utils.qr import qr_svg - facility = db.session.get(Facility, facility_id) - if facility is None: - abort(404) + facility = _facility_for_qr_or_403(facility_id) if not facility.qr_token: facility.ensure_qr_token() @@ -274,12 +325,20 @@ def qr_card(facility_id): @bp.route('/qr-sheet') @login_required -@project_manager_required def qr_sheet(): """Bulk print sheet — one labeled QR card per active facility.""" from app.utils.qr import qr_svg - facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all() + if current_user.role == 'inspector': + abort(403) + + if current_user.role == 'customer': + cids = get_customer_scope(current_user) or [] + facilities = (Facility.query + .filter(Facility.id.in_(cids), Facility.active == True) + .order_by(Facility.name).all()) if cids else [] + else: + facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all() generated = 0 for f in facilities: @@ -297,14 +356,17 @@ def qr_sheet(): @bp.route('//qr/regenerate', methods=['POST']) @login_required -@supervisor_required def regenerate_qr(facility_id): - """Rotate the QR token — invalidates every previously printed poster.""" + """Rotate the QR token — invalidates every previously printed poster. + + Allowed for admin/director, and for customers on their own assigned + facilities. Project managers, auditors and inspectors cannot regenerate. + """ import secrets - facility = db.session.get(Facility, facility_id) - if facility is None: - abort(404) + facility = _facility_for_qr_or_403(facility_id) + if current_user.role not in ('admin', 'director', 'customer'): + abort(403) facility.qr_token = secrets.token_urlsafe(32) db.session.commit() @@ -314,4 +376,232 @@ def regenerate_qr(facility_id): 'QR token regenerated — previously printed QR posters are now invalid') flash('QR code regenerated. Previously printed posters no longer work — ' 'print and post the new code.', 'success') - return redirect(url_for('facilities.qr_card', facility_id=facility_id)) \ No newline at end of file + return redirect(url_for('facilities.qr_card', facility_id=facility_id)) + +# ── Public Area QR codes (phase42) ──────────────────────────────────────────── +# Mirrors the facility QR routes above, but scoped to a single area. Customer +# scope is enforced via the area's parent facility. + +def _area_qr_scan_url(area): + """Absolute public scan URL for an area, built from the current host.""" + area.ensure_qr_token() + return request.host_url.rstrip('/') + url_for('facility_qr.area_scan', + token=area.qr_token) + + +def _area_for_qr_or_403(area_id): + """Load an area for a QR action, enforcing customer facility scope.""" + area = db.session.get(Area, area_id) + if area is None: + abort(404) + # QR management is not an inspector task (matches qr_print_all/qr_export_pdf). + if current_user.role == 'inspector': + abort(403) + if current_user.role == 'customer': + cids = get_customer_scope(current_user) or [] + if area.facility_id not in cids: + abort(403) + return area + + +@bp.route('/areas//qr.png') +@login_required +def area_qr_png(area_id): + """Return the area's QR code as a PNG image.""" + area = _area_for_qr_or_403(area_id) + + created = not area.qr_token + url = _area_qr_scan_url(area) + if created: + db.session.commit() + + from flask import Response + return Response(_qr_png_bytes(url), mimetype='image/png', headers={ + 'Cache-Control': 'private, max-age=3600', + }) + + +@bp.route('/areas//qr') +@login_required +def area_qr_card(area_id): + """Printable page: area name + facility + QR + public URL + instructions.""" + from app.utils.qr import qr_svg + + area = _area_for_qr_or_403(area_id) + + created = not area.qr_token + scan_url = _area_qr_scan_url(area) + if created: + db.session.commit() + logger.info('FACILITIES | area_qr_token_created | user=%s | area_id=%s', + current_user.username, area_id) + + return render_template('facilities/area_qr.html', + area=area, + facility=area.facility, + scan_url=scan_url, + svg=qr_svg(scan_url)) + + +@bp.route('/areas//qr/regenerate', methods=['POST']) +@login_required +def regenerate_area_qr(area_id): + """Rotate an area's QR token — invalidates every previously printed poster. + + Allowed for admin/director, and for customers on their own assigned + facilities. Project managers, auditors and inspectors cannot regenerate. + """ + import secrets + + area = _area_for_qr_or_403(area_id) + if current_user.role not in ('admin', 'director', 'customer'): + abort(403) + + area.qr_token = secrets.token_urlsafe(32) + db.session.commit() + logger.info('FACILITIES | area_qr_token_regenerated | user=%s | area_id=%s', + current_user.username, area_id) + log_action(ACTION_UPDATE, 'Area', area.id, area.name, + 'QR token regenerated — previously printed QR posters are now invalid') + flash('QR code regenerated. Previously printed posters no longer work — ' + 'print and post the new code.', 'warning') + return redirect(url_for('facilities.area_qr_card', area_id=area.id)) + + +# ── Bulk QR print / export (phase42) ────────────────────────────────────────── + +@bp.route('/qr/print-all') +@login_required +def qr_print_all(): + """Printable / selectable sheet of the QR codes the user can see. + + Query params (all optional): + ?contract_id= — limit to one contract; narrows the facility dropdown + ?facility_id= — limit to a single facility + ?include_areas=1 — also render each facility's per-area QR codes + + Inspectors have no QR management (403); customers are scoped to their + assigned facilities; managers see all active facilities. + """ + if current_user.role == 'inspector': + abort(403) + + contract_id = request.args.get('contract_id', type=int) + facility_id = request.args.get('facility_id', type=int) + include_areas = request.args.get('include_areas') in ('1', 'true', 'on') + + # Facilities in the viewer's scope. + if current_user.role == 'customer': + fids = get_customer_scope(current_user) or [] + scoped = Facility.query.filter(Facility.id.in_(fids), Facility.active == True) + else: + scoped = Facility.query.filter(Facility.active == True) + scoped_facilities = scoped.order_by(Facility.name).all() + + # Contract dropdown — only contracts present among the scoped facilities. + contract_ids = {f.project_id for f in scoped_facilities if f.project_id} + contracts = (Project.query + .filter(Project.id.in_(contract_ids)) + .order_by(Project.name).all()) if contract_ids else [] + + # Facility dropdown — narrowed by the selected contract. + facility_options = [f for f in scoped_facilities + if not contract_id or f.project_id == contract_id] + + # The rendered grid — apply the contract + facility filters. + grid_facilities = facility_options + if facility_id: + grid_facilities = [f for f in grid_facilities if f.id == facility_id] + + # Ensure every rendered facility (and area, if requested) has a token so + # its qr.png renders; collect areas keyed by facility id. + changed = False + areas_by_facility = {} + for f in grid_facilities: + if not f.qr_token: + f.ensure_qr_token() + changed = True + if include_areas: + fa = f.areas.order_by(Area.name).all() + for a in fa: + if not a.qr_token: + a.ensure_qr_token() + changed = True + areas_by_facility[f.id] = fa + if changed: + db.session.commit() + + selected_contract = db.session.get(Project, contract_id) if contract_id else None + return render_template('facilities/qr_print_all.html', + facilities=grid_facilities, + areas_by_facility=areas_by_facility, + include_areas=include_areas, + contracts=contracts, + facility_options=facility_options, + selected_contract=selected_contract, + selected_contract_id=contract_id, + selected_facility_id=facility_id) + + +@bp.route('/qr/export-pdf', methods=['POST']) +@login_required +def qr_export_pdf(): + """Export the selected facility + area QR codes to a single PDF. + + Selection arrives as repeated `facility_ids` / `area_ids` form fields. + Scope is enforced per-id via the same helpers as the QR pages, so a + customer can never export a code outside their assigned facilities. + """ + if current_user.role == 'inspector': + abort(403) + + facility_ids = request.form.getlist('facility_ids', type=int) + area_ids = request.form.getlist('area_ids', type=int) + + if not facility_ids and not area_ids: + flash('Select at least one QR code to export.', 'warning') + return redirect(request.referrer or url_for('facilities.qr_print_all')) + + items = [] + for fid in facility_ids: + facility = _facility_for_qr_or_403(fid) # 403 if out of scope + url = _qr_scan_url(facility) + items.append({ + 'title': facility.name, + 'subtitle': facility.project.name if facility.project else None, + 'caption': 'Facility · Report a problem & view recent quality', + 'png': _qr_png_bytes(url), + '_sort': ((facility.name or '').lower(), 0, ''), + }) + for aid in area_ids: + area = _area_for_qr_or_403(aid) # 403 if out of scope + url = _area_qr_scan_url(area) + fac_name = area.facility.name if area.facility else '' + items.append({ + 'title': area.name, + 'subtitle': fac_name or None, + 'caption': 'Area · Report a problem & view recent quality', + 'png': _qr_png_bytes(url), + '_sort': (fac_name.lower(), 1, (area.name or '').lower()), + }) + + # Persist any tokens minted by ensure_qr_token() above. + db.session.commit() + + # Group each facility with its own areas: facility card first, then areas. + items.sort(key=lambda x: x['_sort']) + + from app.utils.pdf_export import generate_qr_codes_pdf + summary = f'{len(facility_ids)} facilit' + ('y' if len(facility_ids) == 1 else 'ies') + summary += f', {len(area_ids)} area' + ('' if len(area_ids) == 1 else 's') + pdf_bytes = generate_qr_codes_pdf(items, filter_summary=summary) + + logger.info('FACILITIES | qr_export_pdf | user=%s | facilities=%s | areas=%s', + current_user.username, len(facility_ids), len(area_ids)) + log_action(ACTION_EXPORT, 'Facility', 0, 'QR Codes', + f'exported {len(facility_ids)} facility + {len(area_ids)} area QR codes to PDF') + + from flask import Response + return Response(pdf_bytes, mimetype='application/pdf', headers={ + 'Content-Disposition': 'attachment; filename="qr_codes.pdf"', + }) diff --git a/app/routes/facility_qr.py b/app/routes/facility_qr.py index 6faf449..5df03cd 100644 --- a/app/routes/facility_qr.py +++ b/app/routes/facility_qr.py @@ -5,7 +5,13 @@ 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: +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 @@ -25,7 +31,8 @@ In multi-tenant mode the printed URL is built from the tenant's own domain import logging from datetime import timedelta -from flask import Blueprint, render_template, redirect, request, url_for, abort +from flask import (Blueprint, render_template, redirect, request, url_for, + abort, flash) from flask_login import current_user from sqlalchemy import func, or_ @@ -33,6 +40,8 @@ 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 @@ -43,6 +52,72 @@ 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 (MT-2). + """ + 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.""" @@ -57,27 +132,37 @@ def _can_view_full(facility): 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) +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( - Inspection.facility_id == facility.id, + *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( - Inspection.facility_id == facility.id, + *insp_scope, Inspection.status == 'completed', Inspection.inspection_date >= d90, Inspection.overall_score.isnot(None), @@ -91,7 +176,7 @@ def scan(token): # ── 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, + *insp_scope, Inspection.status == 'completed', Inspection.overall_score.isnot(None), Inspection.inspection_date >= start, @@ -104,11 +189,6 @@ def scan(token): 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} @@ -130,13 +210,9 @@ def scan(token): 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, + 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, @@ -146,7 +222,7 @@ def scan(token): 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_counts = severity_counts, severity_order = SEVERITY_ORDER, sla_at_risk = sla_at_risk, sla_breached = sla_breached, @@ -157,6 +233,39 @@ def scan(token): ) +@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): @@ -165,42 +274,132 @@ def report(token): 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.query.filter_by(qr_token=token).first() - if facility is None or not facility.active: - abort(404) + facility = _facility_by_token_or_404(token) + form = PublicIssueReportForm() # Honeypot — bots fill this field, humans leave it blank - if request.form.get('website', '').strip(): + 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') - description = request.form.get('description', '').strip() - severity = request.form.get('severity', 'medium') + 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 - if not description: - return redirect(url_for('facility_qr.scan', token=token)) + 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() - logger.info('FACILITY QR REPORT | facility_id=%s issue_id=%s severity=%s', - facility.id, issue.id, severity) + _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) - 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) + # 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') diff --git a/app/templates/facilities/area_qr.html b/app/templates/facilities/area_qr.html new file mode 100644 index 0000000..729ca0b --- /dev/null +++ b/app/templates/facilities/area_qr.html @@ -0,0 +1,81 @@ + + + + + + QR Code — {{ area.name }} + + + + + + + + +{% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} +{% endwith %} + +
+
Area
+

{{ area.name }}

+
{{ facility.name }}
+ {% if area.area_type %}
{{ area.area_type }}
{% endif %} + +
{{ svg | safe }}
+ +
+ Scan to report a problem in this area +
+
+ Recent scores, open issues, and quality trend for {{ area.name }}. +
+
{{ scan_url }}
+
+ +

+ Tip: post this inside the area itself (e.g. on the restroom door), not at the building entrance. +

+ +{% if current_user.role in ['admin', 'director', 'customer'] %} +
+
+ + +
Use this if a printed poster leaked or was posted somewhere it shouldn't be.
+
+
+{% endif %} + + + diff --git a/app/templates/facilities/qr_print_all.html b/app/templates/facilities/qr_print_all.html new file mode 100644 index 0000000..394d922 --- /dev/null +++ b/app/templates/facilities/qr_print_all.html @@ -0,0 +1,182 @@ +{% extends "base.html" %} +{% block title %}Print QR Codes{% endblock %} + +{% block content %} + + +
+
+

QR Codes

+
+ {% if selected_contract %}Contract: {{ selected_contract.name }} — {% endif %} + {{ facilities|length }} facilit{{ 'y' if facilities|length == 1 else 'ies' }} + {% if include_areas %}(with areas){% endif %} +
+
+ + Back + +
+ +{# ── Filter bar (GET reload) ─────────────────────────────────────────────── #} +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ +
+
+
+ +{# ── Selection toolbar + export form ─────────────────────────────────────── #} +
+ + +
+ + + 0 selected +
+ + +
+
+ + {% if facilities %} +
+ {% for f in facilities %} + {# Facility QR card #} + + + {% if include_areas %} + {% for a in areas_by_facility.get(f.id, []) %} + {# Area QR card #} + + {% endfor %} + {% endif %} + {% endfor %} +
+ {% else %} +
No facilities match the selected filters.
+ {% endif %} +
+ +

+ Tip: tick the codes you want, then Print Selected or + Export Selected to PDF. With nothing ticked, Print Selected prints them all. +

+ + +{% endblock %} diff --git a/app/templates/facilities/view.html b/app/templates/facilities/view.html index ba17ce8..465da83 100644 --- a/app/templates/facilities/view.html +++ b/app/templates/facilities/view.html @@ -17,11 +17,15 @@ Scorecard {% endif %} - {% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %} + {% if current_user.role != 'inspector' %} QR Code + + All Codes + {% endif %} {% if current_user.role in ['admin', 'director'] %} @@ -135,6 +139,12 @@ {{ area.inspections.count() }} + {% if current_user.role != 'inspector' %} + + + + {% endif %} {% if current_user.role in ['admin', 'director'] %} diff --git a/app/templates/facility_qr/_report_form.html b/app/templates/facility_qr/_report_form.html new file mode 100644 index 0000000..fb8eb10 --- /dev/null +++ b/app/templates/facility_qr/_report_form.html @@ -0,0 +1,77 @@ +{# ── Public "report a problem" form (phase42) ─────────────────────────────── + Shared by the facility scan page and the area scan page. Caller passes: + action — the POST endpoint URL + form — a PublicIssueReportForm instance + Photos: up to 5, first becomes the issue's primary photo. Honeypot field + `website` is off-screen: humans never see it, bots fill it. +#} +
+
+ Report a Problem +
+
+

+ See something that needs attention? Let our team know and we'll take care of it. +

+ + {% if form.errors %} +
+ Please check the form: +
    + {% for field, errs in form.errors.items() %} + {% for e in errs %}
  • {{ e }}
  • {% endfor %} + {% endfor %} +
+
+ {% endif %} + +
+ + {# Honeypot — invisible to humans, filled by bots #} + + +
+ + {{ form.description(class="form-control form-control-sm", rows=3, + maxlength=2000, + placeholder="Describe the issue (e.g. restroom out of paper towels, spill in lobby…)") }} +
+ +
+ + {{ form.severity(class="form-select form-select-sm") }} +
+ +
+ + {{ form.area_label(class="form-control form-control-sm", + placeholder="e.g. 2nd floor men's restroom") }} +
+ +
+ + {{ form.photos(class="form-control form-control-sm", accept="image/*") }} +
A photo helps our team find and fix it faster.
+
+ +
+
+ + {{ form.reporter_name(class="form-control form-control-sm") }} +
+
+ + {{ form.reporter_contact(class="form-control form-control-sm") }} +
+
+ + +
+
+
diff --git a/app/templates/facility_qr/area.html b/app/templates/facility_qr/area.html new file mode 100644 index 0000000..e0204a7 --- /dev/null +++ b/app/templates/facility_qr/area.html @@ -0,0 +1,196 @@ + + + + + + + {{ area.name }} — {{ facility.name }} + + + + + +
+ + {% if request.args.get('reported') == '1' %} + + {% endif %} + +
+ +
+
{{ area.name }}
+
+ {{ facility.name }} + {% if area.area_type %} · {{ area.area_type }}{% endif %} +
+
+
+ +
+ + Everything below is for {{ area.name }} only — not the whole building. +
+ + {# ── Summary stat tiles (90 days) ── #} +
+
+
+
{% if avg_90 is not none %}{{ '%.1f'|format(avg_90) }}%{% else %}—{% endif %}
+
Avg Score
90 days
+
+
+
+
+
{{ total_90 }}
+
Inspections
90 days
+
+
+
+
+
{{ open_total }}
+
Open
Issues
+
+
+
+
+
{{ resolved_90 }}
+
Resolved
90 days
+
+
+
+ + {# ── Score trend ── #} +
+
+
Score trend — 30 days vs prior 30
+ {% if trend_delta is not none %} + {% if trend_delta > 0.5 %} +
+ Improving + (+{{ '%.1f'|format(trend_delta) }} pts, {{ '%.1f'|format(avg_prior) }}% → {{ '%.1f'|format(avg_cur) }}%) +
+ {% elif trend_delta < -0.5 %} +
+ Declining + ({{ '%.1f'|format(trend_delta) }} pts, {{ '%.1f'|format(avg_prior) }}% → {{ '%.1f'|format(avg_cur) }}%) +
+ {% else %} +
+ Steady ({{ '%.1f'|format(avg_cur) }}%) +
+ {% endif %} + {% else %} +
Not enough data yet
+ {% endif %} +
+
+ + {# ── Open issues by severity + SLA state ── #} +
+
+ Open Issues +
+
+ {% if open_total or pending_verification %} +
+ {% for sev in severity_order %} + {% if severity_counts[sev] %} + + {{ severity_counts[sev] }} {{ sev }} + + {% endif %} + {% endfor %} + {% if pending_verification %} + {{ pending_verification }} pending verification + {% endif %} +
+ {% if sla_breached %} +
+ {{ sla_breached }} issue{{ 's' if sla_breached != 1 }} past the response-time target +
+ {% endif %} + {% if sla_at_risk %} +
+ {{ sla_at_risk }} issue{{ 's' if sla_at_risk != 1 }} approaching the response-time target +
+ {% endif %} + {% if not sla_breached and not sla_at_risk and open_total %} +
All open issues are within response-time targets.
+ {% endif %} + {% else %} +
No open issues in this area right now.
+ {% endif %} +
+
+ + {# ── Recent inspections ── #} +
+
+ Recent Inspections +
+ {% if recent %} +
+ + + + + + + + + + {% for ins in recent %} + + + + + + {% endfor %} + +
DateChecklistScore
{{ ins.inspection_date.strftime('%b %d, %Y') }}{{ ins.template.name if ins.template else '—' }} + {% if ins.overall_score is not none %}{{ '%.1f'|format(ins.overall_score) }}%{% else %}—{% endif %} +
+
+ {% else %} +
No completed inspections for this area yet.
+ {% endif %} +
+ + {% if can_view_full %} +
+ Open Full Facility View + + {% endif %} + + {% with action = url_for('facility_qr.area_report', token=token), + area_label_prompt = 'Whereabouts in ' ~ area.name ~ '? (optional)' %} + {% include "facility_qr/_report_form.html" %} + {% endwith %} + +

+ {% if last_date %}Last inspected {{ last_date.strftime('%b %d, %Y') }} · {% endif %} + Snapshot generated {{ generated_at.strftime('%b %d, %Y %I:%M %p') }} ET +

+

Janitorial QC — area quality snapshot

+
+ + diff --git a/app/templates/facility_qr/view.html b/app/templates/facility_qr/view.html index e4b3d9a..5ab7ac2 100644 --- a/app/templates/facility_qr/view.html +++ b/app/templates/facility_qr/view.html @@ -177,41 +177,9 @@ {% endif %} - {# ── Report a problem ── #} -
-
- Report a Problem -
-
-

- See something that needs attention? Let our team know and we'll take care of it. -

-
- - {# Honeypot — invisible to humans, filled by bots #} - -
- - -
-
- - -
- -
-
-
+ {% with action = url_for('facility_qr.report', token=token) %} + {% include "facility_qr/_report_form.html" %} + {% endwith %}

{% if last_date %}Last inspected {{ last_date.strftime('%b %d, %Y') }} · {% endif %} diff --git a/app/utils/forms.py b/app/utils/forms.py index 46c38ce..9d7da82 100644 --- a/app/utils/forms.py +++ b/app/utils/forms.py @@ -319,4 +319,34 @@ class SetPasswordForm(FlaskForm): def validate_username(self, field): existing = User.query.filter_by(username=field.data.strip()).first() if existing: - raise ValidationError('This username is already taken. Please choose another.') \ No newline at end of file + raise ValidationError('This username is already taken. Please choose another.') + +# ── Public QR issue report (phase42) ───────────────────────────────────────── + +class PublicIssueReportForm(FlaskForm): + """Login-free issue report submitted from a facility's or area's public QR page. + + Ported from the single-tenant tree, with MT's occupant-chosen `severity` + field retained (the ST original always filed at 'medium'). + + `website` is a honeypot: real users never see it (hidden via CSS); bots + that fill every field trip it and the submission is silently rejected. + """ + area_label = StringField('Where in the building?', + validators=[Optional(), Length(max=120)]) + description = TextAreaField('Describe the problem', + validators=[DataRequired(), Length(min=5, max=2000)]) + severity = SelectField('How urgent is it?', choices=[ + ('low', 'Minor — can wait'), + ('medium', 'Normal'), + ('high', 'Urgent — needs attention today'), + ], default='medium', validators=[Optional()]) + reporter_name = StringField('Your name (optional)', + validators=[Optional(), Length(max=100)]) + reporter_contact = StringField('Email or phone (optional)', + validators=[Optional(), Length(max=120)]) + photos = MultipleFileField('Add photos (optional, up to 5)', + validators=[Optional(), + FileAllowed(['jpg', 'jpeg', 'png', 'gif'], + 'Images only (jpg, png, gif).')]) + website = StringField('Website') # honeypot — must stay empty diff --git a/app/utils/pdf_export.py b/app/utils/pdf_export.py index 5424eb3..af8e910 100644 --- a/app/utils/pdf_export.py +++ b/app/utils/pdf_export.py @@ -1589,4 +1589,96 @@ def generate_facility_summary_pdf(facility, days, start, now, )) doc.build(story) return buf.getvalue() - return buf.getvalue() \ No newline at end of file + return buf.getvalue() + + +# ── QR code sheet (phase42) ─────────────────────────────────────────────────── + +def generate_qr_codes_pdf(items, filter_summary: str = '') -> bytes: + """Return a PDF byte-string laying out selected QR codes in a grid. + + Parameters + ---------- + items : list of dicts, each: + { + 'title': str, # main label (facility or area name) + 'subtitle': str | None, # e.g. contract name, or parent facility + 'caption': str | None, # small line under the QR + 'png': bytes, # QR code PNG image bytes + } + filter_summary : human-readable string describing the selection (optional) + """ + buf = io.BytesIO() + generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET') + report_title = 'QR Codes' + + doc = SimpleDocTemplate( + buf, + pagesize=letter, + leftMargin=0.65 * inch, + rightMargin=0.65 * inch, + topMargin=1.35 * inch, + bottomMargin=0.75 * inch, + title=report_title, + author='Janitorial QC System', + ) + + def _page_cb(canvas, doc): + _on_page(canvas, doc, report_title, generated_at) + + story = [] + if filter_summary: + story.append(Paragraph(f'Filters: {filter_summary}', STYLES['ReportSub'])) + story.append(Paragraph( + f'Total codes: {len(items)}', STYLES['ReportSub'])) + story.append(Spacer(1, 10)) + + if not items: + story.append(Paragraph('No QR codes selected.', STYLES['FieldValue'])) + doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb) + return buf.getvalue() + + COLS = 3 + pw = letter[0] - 1.3 * inch # usable width + cell_w = pw / COLS + qr_size = 1.7 * inch + + title_style = ParagraphStyle('QRT', fontName='Helvetica-Bold', fontSize=9, + alignment=TA_CENTER, leading=11, textColor=C_DARK) + sub_style = ParagraphStyle('QRS', fontName='Helvetica', fontSize=7.5, + alignment=TA_CENTER, leading=9, textColor=C_SLATE) + cap_style = ParagraphStyle('QRC', fontName='Helvetica', fontSize=6.5, + alignment=TA_CENTER, leading=8, textColor=C_SLATE) + + def _cell(item): + flow = [Paragraph(item.get('title') or '', title_style)] + if item.get('subtitle'): + flow.append(Paragraph(item['subtitle'], sub_style)) + flow.append(Spacer(1, 4)) + flow.append(RLImage(io.BytesIO(item['png']), width=qr_size, height=qr_size)) + if item.get('caption'): + flow.append(Spacer(1, 3)) + flow.append(Paragraph(item['caption'], cap_style)) + return flow + + rows = [] + for i in range(0, len(items), COLS): + chunk = items[i:i + COLS] + row = [_cell(it) for it in chunk] + while len(row) < COLS: + row.append('') # filler cell to keep the grid rectangular + rows.append(row) + + tbl = Table(rows, colWidths=[cell_w] * COLS) + tbl.setStyle(TableStyle([ + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('TOPPADDING', (0, 0), (-1, -1), 10), + ('BOTTOMPADDING', (0, 0), (-1, -1), 16), + ('LEFTPADDING', (0, 0), (-1, -1), 6), + ('RIGHTPADDING', (0, 0), (-1, -1), 6), + ])) + story.append(tbl) + + doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb) + return buf.getvalue() diff --git a/migrations/versions/phase42_area_qr_token.py b/migrations/versions/phase42_area_qr_token.py new file mode 100644 index 0000000..f5aebbc --- /dev/null +++ b/migrations/versions/phase42_area_qr_token.py @@ -0,0 +1,67 @@ +"""phase42 — areas.qr_token for per-area public QR landing pages + +Adds a unique, unguessable token per area. Each area's QR encodes +/f/area/, a login-free summary scoped to that area plus a +"report a problem" form. + +Ported from the single-tenant chain (phase39_area_public_token) and adapted: +the MT column is named `qr_token` (matching `facilities.qr_token` from +phase38_facility_qr), not `public_token`, and is VARCHAR(64) to match the +facility column and MT's `secrets.token_urlsafe(32)` generator. + +Unlike the ST original, existing areas are NOT backfilled: MT mints tokens +lazily via `Area.ensure_qr_token()` on first use, exactly as +`Facility.ensure_qr_token()` already does. A backfill would mint tokens for +areas nobody ever prints a code for. + +Uses INFORMATION_SCHEMA checks — safe to re-run on every tenant DB. +""" + +revision = 'phase42_area_qr_token' +down_revision = 'phase41_auditor_role' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def _column_exists(conn, table, column): + return conn.execute(sa.text( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() " + "AND TABLE_NAME = :t AND COLUMN_NAME = :c" + ), {"t": table, "c": column}).scalar() > 0 + + +def _index_exists(conn, table, index): + return conn.execute(sa.text( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS " + "WHERE TABLE_SCHEMA = DATABASE() " + "AND TABLE_NAME = :t AND INDEX_NAME = :i" + ), {"t": table, "i": index}).scalar() > 0 + + +def upgrade(): + bind = op.get_bind() + + if not _column_exists(bind, 'areas', 'qr_token'): + op.execute(sa.text( + "ALTER TABLE areas ADD COLUMN qr_token VARCHAR(64) NULL" + )) + + # Unique index tolerates multiple NULLs in MySQL, so it can be created + # immediately — no backfill needed before enforcing uniqueness. + if not _index_exists(bind, 'areas', 'uq_area_qr_token'): + op.execute(sa.text( + "CREATE UNIQUE INDEX uq_area_qr_token ON areas (qr_token)" + )) + + +def downgrade(): + bind = op.get_bind() + + if _index_exists(bind, 'areas', 'uq_area_qr_token'): + op.execute(sa.text("DROP INDEX uq_area_qr_token ON areas")) + if _column_exists(bind, 'areas', 'qr_token'): + op.execute(sa.text("ALTER TABLE areas DROP COLUMN qr_token"))