Jul 16 - Fill the gaps between Single-tenant mode and Multi-tenant mode - MT3

This commit is contained in:
2026-07-16 17:17:44 -04:00
parent a60afc6c41
commit 02b030b1d2
12 changed files with 1294 additions and 90 deletions
+12
View File
@@ -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/<token>).
# 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')
+303 -13
View File
@@ -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('/<int:facility_id>/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('/<int:facility_id>/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('/<int:facility_id>/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))
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/<int:area_id>/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/<int:area_id>/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/<int:area_id>/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=<id> — limit to one contract; narrows the facility dropdown
?facility_id=<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"',
})
+238 -39
View File
@@ -5,7 +5,13 @@ 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:
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
@@ -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('/<token>')
@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('/<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):
@@ -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/<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')
+81
View File
@@ -0,0 +1,81 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>QR Code — {{ area.name }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<style>
body { background:#f1f5f9; }
.qr-card { max-width:420px; margin:2rem auto; background:#fff; border-radius:.75rem;
box-shadow:0 1px 3px rgba(0,0,0,.12); padding:2rem; text-align:center; }
.qr-box svg { width:260px; height:260px; }
.scan-url { word-break:break-all; font-size:.72rem; color:#94a3b8; }
@media print {
body { background:#fff; }
.no-print { display:none !important; }
.qr-card { box-shadow:none; margin:0 auto; }
}
</style>
</head>
<body>
<div class="text-center mt-3 no-print">
<a href="{{ url_for('facilities.view_facility', facility_id=facility.id) }}"
class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Back to Facility
</a>
<button onclick="window.print()" class="btn btn-primary btn-sm">
<i class="bi bi-printer"></i> Print
</button>
<a href="{{ url_for('facilities.qr_print_all', facility_id=facility.id, include_areas=1) }}"
class="btn btn-outline-primary btn-sm">
<i class="bi bi-grid-3x3-gap"></i> Print All Codes
</a>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ 'success' if category == 'success' else 'warning' }} mx-auto mt-3 no-print" style="max-width:420px;">
{{ message }}
</div>
{% endfor %}
{% endwith %}
<div class="qr-card">
<div class="text-muted text-uppercase small" style="letter-spacing:.08em;">Area</div>
<h4 class="mb-0">{{ area.name }}</h4>
<div class="text-muted small">{{ facility.name }}</div>
{% if area.area_type %}<div class="text-muted small mb-2">{{ area.area_type }}</div>{% endif %}
<div class="qr-box my-3">{{ svg | safe }}</div>
<div class="fw-semibold mb-1">
<i class="bi bi-phone"></i> Scan to report a problem in this area
</div>
<div class="text-muted small mb-2">
Recent scores, open issues, and quality trend for {{ area.name }}.
</div>
<div class="scan-url">{{ scan_url }}</div>
</div>
<p class="text-center text-muted small no-print">
Tip: post this inside the area itself (e.g. on the restroom door), not at the building entrance.
</p>
{% if current_user.role in ['admin', 'director', 'customer'] %}
<div class="text-center mb-4 no-print">
<form method="POST" action="{{ url_for('facilities.regenerate_area_qr', area_id=area.id) }}"
onsubmit="return confirm('Regenerate this QR code? Every previously printed poster for {{ area.name }} will stop working.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-outline-danger btn-sm">
<i class="bi bi-arrow-repeat"></i> Regenerate QR Code
</button>
<div class="form-text">Use this if a printed poster leaked or was posted somewhere it shouldn't be.</div>
</form>
</div>
{% endif %}
</body>
</html>
+182
View File
@@ -0,0 +1,182 @@
{% extends "base.html" %}
{% block title %}Print QR Codes{% endblock %}
{% block content %}
<style>
.qr-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
.qr-item { position: relative; break-inside: avoid; page-break-inside: avoid;
text-align: center; cursor: pointer; }
.qr-item img { width: 220px; height: 220px; max-width: 100%; }
.qr-item.qr-selected { outline: 3px solid #2563eb; outline-offset: -1px; }
.qr-check { position: absolute; top: 10px; left: 10px; }
.qr-check .form-check-input { width: 1.25rem; height: 1.25rem; }
.qr-kind { font-size: .68rem; letter-spacing: .08em; }
@media print {
.no-print { display: none !important; }
.navbar, nav, footer { display: none !important; }
.qr-item { border: 1px dashed #bbb !important; cursor: default; }
.qr-item.qr-selected { outline: none !important; }
.qr-grid { gap: 8px; }
/* When printing a selection, hide the unselected cards. */
body.print-selected-only .qr-item:not(.qr-selected) { display: none !important; }
}
</style>
<div class="d-flex justify-content-between align-items-center mb-3 no-print">
<div>
<h2 class="h4 mb-0"><i class="bi bi-qr-code"></i> QR Codes</h2>
<div class="text-muted small">
{% if selected_contract %}Contract: {{ selected_contract.name }} — {% endif %}
{{ facilities|length }} facilit{{ 'y' if facilities|length == 1 else 'ies' }}
{% if include_areas %}(with areas){% endif %}
</div>
</div>
<a href="{{ url_for('facilities.list_facilities') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Back
</a>
</div>
{# ── Filter bar (GET reload) ─────────────────────────────────────────────── #}
<form method="get" id="filterForm" class="card card-body mb-3 no-print">
<div class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small fw-semibold mb-1">Contract</label>
<select name="contract_id" class="form-select form-select-sm"
onchange="document.getElementById('facilitySelect').value=''; this.form.submit();">
<option value="">All Contracts</option>
{% for c in contracts %}
<option value="{{ c.id }}" {{ 'selected' if selected_contract_id == c.id }}>{{ c.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<label class="form-label small fw-semibold mb-1">Facility</label>
<select name="facility_id" id="facilitySelect" class="form-select form-select-sm">
<option value="">All Facilities</option>
{% for f in facility_options %}
<option value="{{ f.id }}" {{ 'selected' if selected_facility_id == f.id }}>{{ f.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="include_areas" value="1"
id="includeAreas" {{ 'checked' if include_areas }}>
<label class="form-check-label small" for="includeAreas">Include area QR codes</label>
</div>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary btn-sm w-100">
<i class="bi bi-funnel"></i> Apply
</button>
</div>
</div>
</form>
{# ── Selection toolbar + export form ─────────────────────────────────────── #}
<form method="post" action="{{ url_for('facilities.qr_export_pdf') }}" id="qrForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="d-flex align-items-center gap-2 mb-3 no-print flex-wrap">
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="selectAllQr(true)">Select All</button>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="selectAllQr(false)">Clear</button>
<span class="text-muted small" id="selCount">0 selected</span>
<div class="ms-auto d-flex gap-2">
<button type="button" class="btn btn-primary btn-sm" onclick="printSelected()">
<i class="bi bi-printer"></i> Print Selected
</button>
<button type="submit" class="btn btn-danger btn-sm">
<i class="bi bi-file-earmark-pdf"></i> Export Selected to PDF
</button>
</div>
</div>
{% if facilities %}
<div class="qr-grid">
{% for f in facilities %}
{# Facility QR card #}
<label class="qr-item card shadow-sm p-3 mb-0">
<div class="qr-check no-print">
<input type="checkbox" class="form-check-input qr-cb" name="facility_ids" value="{{ f.id }}">
</div>
<div class="text-muted text-uppercase qr-kind">Facility</div>
<div class="fw-bold">{{ f.name }}</div>
{% if f.project %}
<div class="text-muted small mb-1">{{ f.project.name }}</div>
{% endif %}
<div>
<img src="{{ url_for('facilities.facility_qr_png', facility_id=f.id) }}"
alt="QR code for {{ f.name }}" loading="lazy">
</div>
<div class="small">Report a problem &amp; view recent quality</div>
</label>
{% if include_areas %}
{% for a in areas_by_facility.get(f.id, []) %}
{# Area QR card #}
<label class="qr-item card shadow-sm p-3 mb-0">
<div class="qr-check no-print">
<input type="checkbox" class="form-check-input qr-cb" name="area_ids" value="{{ a.id }}">
</div>
<div class="text-muted text-uppercase qr-kind">Area</div>
<div class="fw-bold">{{ a.name }}</div>
<div class="text-muted small mb-1">{{ f.name }}</div>
<div>
<img src="{{ url_for('facilities.area_qr_png', area_id=a.id) }}"
alt="QR code for {{ a.name }}" loading="lazy">
</div>
<div class="small">Report a problem &amp; view recent quality</div>
</label>
{% endfor %}
{% endif %}
{% endfor %}
</div>
{% else %}
<div class="alert alert-info">No facilities match the selected filters.</div>
{% endif %}
</form>
<p class="text-muted small mt-3 no-print">
Tip: tick the codes you want, then <strong>Print Selected</strong> or
<strong>Export Selected to PDF</strong>. With nothing ticked, Print Selected prints them all.
</p>
<script>
(function () {
'use strict';
function checkboxes() { return document.querySelectorAll('.qr-cb'); }
function updateCount() {
var n = document.querySelectorAll('.qr-cb:checked').length;
document.getElementById('selCount').textContent = n + ' selected';
}
function syncCard(cb) {
var card = cb.closest('.qr-item');
if (card) { card.classList.toggle('qr-selected', cb.checked); }
}
window.selectAllQr = function (state) {
checkboxes().forEach(function (cb) { cb.checked = state; syncCard(cb); });
updateCount();
};
window.printSelected = function () {
var anySelected = document.querySelectorAll('.qr-cb:checked').length > 0;
if (anySelected) { document.body.classList.add('print-selected-only'); }
window.print();
setTimeout(function () {
document.body.classList.remove('print-selected-only');
}, 500);
};
// Toggling a checkbox inside its <label> card also fires on the label click.
checkboxes().forEach(function (cb) {
cb.addEventListener('change', function () { syncCard(cb); updateCount(); });
});
updateCount();
}());
</script>
{% endblock %}
+11 -1
View File
@@ -17,11 +17,15 @@
<i class="bi bi-graph-up-arrow"></i> Scorecard
</a>
{% endif %}
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
{% if current_user.role != 'inspector' %}
<a href="{{ url_for('facilities.qr_card', facility_id=facility.id) }}"
class="btn btn-outline-secondary">
<i class="bi bi-qr-code"></i> QR Code
</a>
<a href="{{ url_for('facilities.qr_print_all', facility_id=facility.id, include_areas=1) }}"
class="btn btn-outline-secondary" title="Print or export this facility's codes, including every area">
<i class="bi bi-grid-3x3-gap"></i> All Codes
</a>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('facilities.edit_facility', facility_id=facility.id) }}" class="btn btn-outline-primary">
@@ -135,6 +139,12 @@
</td>
<td>{{ area.inspections.count() }}</td>
<td>
{% if current_user.role != 'inspector' %}
<a href="{{ url_for('facilities.area_qr_card', area_id=area.id) }}"
class="btn btn-sm btn-outline-secondary" title="Printable QR code for this area">
<i class="bi bi-qr-code"></i>
</a>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('facilities.edit_area', area_id=area.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i>
@@ -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.
#}
<div class="card shadow-sm mb-3">
<div class="card-header bg-white fw-semibold py-2">
<i class="bi bi-megaphone text-danger"></i> Report a Problem
</div>
<div class="card-body">
<p class="text-muted small mb-3">
See something that needs attention? Let our team know and we'll take care of it.
</p>
{% if form.errors %}
<div class="alert alert-danger py-2 small">
<strong>Please check the form:</strong>
<ul class="mb-0 ps-3">
{% for field, errs in form.errors.items() %}
{% for e in errs %}<li>{{ e }}</li>{% endfor %}
{% endfor %}
</ul>
</div>
{% endif %}
<form method="POST" action="{{ action }}" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{# Honeypot — invisible to humans, filled by bots #}
<div style="position:absolute;left:-9999px;top:-9999px;" aria-hidden="true">
{{ form.website(tabindex="-1", autocomplete="off") }}
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">
What did you observe? <span class="text-danger">*</span>
</label>
{{ 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…)") }}
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Urgency</label>
{{ form.severity(class="form-select form-select-sm") }}
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{{ area_label_prompt or 'Where in the building?' }}</label>
{{ form.area_label(class="form-control form-control-sm",
placeholder="e.g. 2nd floor men's restroom") }}
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Add photos (optional, up to 5)</label>
{{ form.photos(class="form-control form-control-sm", accept="image/*") }}
<div class="form-text small">A photo helps our team find and fix it faster.</div>
</div>
<div class="row g-2 mb-3">
<div class="col-6">
<label class="form-label small fw-semibold">Your name (optional)</label>
{{ form.reporter_name(class="form-control form-control-sm") }}
</div>
<div class="col-6">
<label class="form-label small fw-semibold">Email or phone (optional)</label>
{{ form.reporter_contact(class="form-control form-control-sm") }}
</div>
</div>
<button type="submit" class="btn btn-danger btn-sm w-100">
<i class="bi bi-send me-1"></i> Submit Report
</button>
</form>
</div>
</div>
+196
View File
@@ -0,0 +1,196 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>{{ area.name }} — {{ facility.name }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<style>
body { background:#f1f5f9; color:#1f2937; }
.fq-wrap { max-width:640px; margin:2rem auto; padding:0 1rem; }
.stat-tile { background:#fff; border-radius:.5rem; padding:.9rem .5rem; text-align:center;
box-shadow:0 1px 2px rgba(0,0,0,.06); height:100%; }
.stat-tile .val { font-size:1.6rem; font-weight:700; line-height:1.2; }
.stat-tile .lbl { font-size:.72rem; color:#64748b; text-transform:uppercase;
letter-spacing:.03em; margin-top:.15rem; }
.sev-critical{background:#dc2626}.sev-high{background:#ea580c}
.sev-medium{background:#d97706}.sev-low{background:#64748b}
.trend-up { color:#15803d; }
.trend-down { color:#dc2626; }
.trend-flat { color:#64748b; }
@media (max-width:576px){ .fq-wrap{ margin:1rem auto; } }
</style>
</head>
<body>
<div class="fq-wrap">
{% if request.args.get('reported') == '1' %}
<div class="alert alert-success d-flex align-items-center gap-2 mb-3" role="alert">
<i class="bi bi-check-circle-fill fs-5"></i>
<div><strong>Report submitted.</strong> Our team has been notified and will follow up.</div>
</div>
{% endif %}
<div class="d-flex align-items-center gap-2 mb-3">
<i class="bi bi-door-open fs-3 text-primary"></i>
<div>
<div class="fw-bold">{{ area.name }}</div>
<div class="text-muted small">
{{ facility.name }}
{% if area.area_type %} · {{ area.area_type }}{% endif %}
</div>
</div>
</div>
<div class="alert alert-light border py-2 small mb-3">
<i class="bi bi-info-circle text-primary"></i>
Everything below is for <strong>{{ area.name }}</strong> only — not the whole building.
</div>
{# ── Summary stat tiles (90 days) ── #}
<div class="row g-2 mb-3">
<div class="col-3">
<div class="stat-tile">
<div class="val">{% if avg_90 is not none %}{{ '%.1f'|format(avg_90) }}%{% else %}—{% endif %}</div>
<div class="lbl">Avg Score<br>90 days</div>
</div>
</div>
<div class="col-3">
<div class="stat-tile">
<div class="val">{{ total_90 }}</div>
<div class="lbl">Inspections<br>90 days</div>
</div>
</div>
<div class="col-3">
<div class="stat-tile">
<div class="val">{{ open_total }}</div>
<div class="lbl">Open<br>Issues</div>
</div>
</div>
<div class="col-3">
<div class="stat-tile">
<div class="val">{{ resolved_90 }}</div>
<div class="lbl">Resolved<br>90 days</div>
</div>
</div>
</div>
{# ── Score trend ── #}
<div class="card shadow-sm mb-3">
<div class="card-body py-2 d-flex align-items-center justify-content-between">
<div class="text-muted small text-uppercase">Score trend — 30 days vs prior 30</div>
{% if trend_delta is not none %}
{% if trend_delta > 0.5 %}
<div class="trend-up fw-semibold">
<i class="bi bi-arrow-up-right"></i> Improving
(+{{ '%.1f'|format(trend_delta) }} pts, {{ '%.1f'|format(avg_prior) }}% → {{ '%.1f'|format(avg_cur) }}%)
</div>
{% elif trend_delta < -0.5 %}
<div class="trend-down fw-semibold">
<i class="bi bi-arrow-down-right"></i> Declining
({{ '%.1f'|format(trend_delta) }} pts, {{ '%.1f'|format(avg_prior) }}% → {{ '%.1f'|format(avg_cur) }}%)
</div>
{% else %}
<div class="trend-flat fw-semibold">
<i class="bi bi-arrow-right"></i> Steady ({{ '%.1f'|format(avg_cur) }}%)
</div>
{% endif %}
{% else %}
<div class="text-muted small">Not enough data yet</div>
{% endif %}
</div>
</div>
{# ── Open issues by severity + SLA state ── #}
<div class="card shadow-sm mb-3">
<div class="card-header bg-white fw-semibold py-2">
<i class="bi bi-exclamation-triangle"></i> Open Issues
</div>
<div class="card-body py-3">
{% if open_total or pending_verification %}
<div class="d-flex flex-wrap gap-2 mb-2">
{% for sev in severity_order %}
{% if severity_counts[sev] %}
<span class="badge sev-{{ sev }} text-white">
{{ severity_counts[sev] }} {{ sev }}
</span>
{% endif %}
{% endfor %}
{% if pending_verification %}
<span class="badge bg-info text-dark">{{ pending_verification }} pending verification</span>
{% endif %}
</div>
{% if sla_breached %}
<div class="small text-danger fw-semibold">
<i class="bi bi-alarm"></i> {{ sla_breached }} issue{{ 's' if sla_breached != 1 }} past the response-time target
</div>
{% endif %}
{% if sla_at_risk %}
<div class="small text-warning-emphasis fw-semibold">
<i class="bi bi-hourglass-split"></i> {{ sla_at_risk }} issue{{ 's' if sla_at_risk != 1 }} approaching the response-time target
</div>
{% endif %}
{% if not sla_breached and not sla_at_risk and open_total %}
<div class="small text-muted">All open issues are within response-time targets.</div>
{% endif %}
{% else %}
<div class="text-muted small"><i class="bi bi-check-circle text-success"></i> No open issues in this area right now.</div>
{% endif %}
</div>
</div>
{# ── Recent inspections ── #}
<div class="card shadow-sm mb-3">
<div class="card-header bg-white fw-semibold py-2">
<i class="bi bi-clipboard-check"></i> Recent Inspections
</div>
{% if recent %}
<div class="table-responsive">
<table class="table table-sm mb-0 align-middle">
<thead class="table-light">
<tr>
<th class="ps-3">Date</th>
<th>Checklist</th>
<th class="text-end pe-3">Score</th>
</tr>
</thead>
<tbody>
{% for ins in recent %}
<tr>
<td class="ps-3 text-nowrap">{{ ins.inspection_date.strftime('%b %d, %Y') }}</td>
<td class="text-muted small">{{ ins.template.name if ins.template else '—' }}</td>
<td class="text-end pe-3 fw-semibold">
{% if ins.overall_score is not none %}{{ '%.1f'|format(ins.overall_score) }}%{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card-body py-3 text-muted small">No completed inspections for this area yet.</div>
{% endif %}
</div>
{% if can_view_full %}
<a href="{{ url_for('facilities.view_facility', facility_id=facility.id) }}"
class="btn btn-primary w-100 mb-3">
<i class="bi bi-box-arrow-in-right me-1"></i> Open Full Facility View
</a>
{% 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 %}
<p class="text-center text-muted small mt-2 mb-1">
{% if last_date %}Last inspected {{ last_date.strftime('%b %d, %Y') }} · {% endif %}
Snapshot generated {{ generated_at.strftime('%b %d, %Y %I:%M %p') }} ET
</p>
<p class="text-center text-muted small">Janitorial QC — area quality snapshot</p>
</div>
</body>
</html>
+3 -35
View File
@@ -177,41 +177,9 @@
</a>
{% endif %}
{# ── Report a problem ── #}
<div class="card shadow-sm mb-3">
<div class="card-header bg-white fw-semibold py-2">
<i class="bi bi-megaphone text-danger"></i> Report a Problem
</div>
<div class="card-body">
<p class="text-muted small mb-3">
See something that needs attention? Let our team know and we'll take care of it.
</p>
<form method="POST" action="{{ url_for('facility_qr.report', token=token) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{# Honeypot — invisible to humans, filled by bots #}
<div style="position:absolute;left:-9999px;top:-9999px;" aria-hidden="true">
<input type="text" name="website" tabindex="-1" autocomplete="off">
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">What did you observe? <span class="text-danger">*</span></label>
<textarea name="description" class="form-control form-control-sm" rows="3"
placeholder="Describe the issue (e.g. restroom out of paper towels, spill in lobby…)"
required maxlength="2000"></textarea>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Urgency</label>
<select name="severity" class="form-select form-select-sm">
<option value="low">Low — not urgent</option>
<option value="medium" selected>Medium — needs attention soon</option>
<option value="high">High — urgent</option>
</select>
</div>
<button type="submit" class="btn btn-danger btn-sm w-100">
<i class="bi bi-send me-1"></i> Submit Report
</button>
</form>
</div>
</div>
{% with action = url_for('facility_qr.report', token=token) %}
{% include "facility_qr/_report_form.html" %}
{% endwith %}
<p class="text-center text-muted small mt-2 mb-1">
{% if last_date %}Last inspected {{ last_date.strftime('%b %d, %Y') }} · {% endif %}
+31 -1
View File
@@ -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.')
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
+93 -1
View File
@@ -1589,4 +1589,96 @@ def generate_facility_summary_pdf(facility, days, start, now,
))
doc.build(story)
return buf.getvalue()
return buf.getvalue()
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()