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) name = db.Column(db.String(255), nullable=False)
area_type = db.Column(db.String(50)) 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 # Relationships
inspections = db.relationship('Inspection', backref='area', lazy='dynamic') inspections = db.relationship('Inspection', backref='area', lazy='dynamic')
issues = db.relationship('Issue', backref='area', lazy='dynamic') issues = db.relationship('Issue', backref='area', lazy='dynamic')
+302 -12
View File
@@ -6,7 +6,8 @@ from app.models.facility import Facility, Area
from app.models.project import Project from app.models.project import Project
from app.utils.forms import FacilityForm, AreaForm from app.utils.forms import FacilityForm, AreaForm
from app.utils.decorators import supervisor_required, admin_required, project_manager_required 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.utils.scope import get_customer_scope, get_inspector_scope
from app.tenancy.gates import quota_soft_check from app.tenancy.gates import quota_soft_check
@@ -244,20 +245,70 @@ def delete_area(area_id):
def _qr_scan_url(facility): def _qr_scan_url(facility):
"""Absolute public scan URL, built from the current host (rule 64 pattern).""" """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', return request.host_url.rstrip('/') + url_for('facility_qr.scan',
token=facility.qr_token) 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') @bp.route('/<int:facility_id>/qr')
@login_required @login_required
@project_manager_required
def qr_card(facility_id): def qr_card(facility_id):
"""Printable QR card for one facility. Generates the token on first use.""" """Printable QR card for one facility. Generates the token on first use."""
from app.utils.qr import qr_svg from app.utils.qr import qr_svg
facility = db.session.get(Facility, facility_id) facility = _facility_for_qr_or_403(facility_id)
if facility is None:
abort(404)
if not facility.qr_token: if not facility.qr_token:
facility.ensure_qr_token() facility.ensure_qr_token()
@@ -274,12 +325,20 @@ def qr_card(facility_id):
@bp.route('/qr-sheet') @bp.route('/qr-sheet')
@login_required @login_required
@project_manager_required
def qr_sheet(): def qr_sheet():
"""Bulk print sheet — one labeled QR card per active facility.""" """Bulk print sheet — one labeled QR card per active facility."""
from app.utils.qr import qr_svg 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 generated = 0
for f in facilities: for f in facilities:
@@ -297,14 +356,17 @@ def qr_sheet():
@bp.route('/<int:facility_id>/qr/regenerate', methods=['POST']) @bp.route('/<int:facility_id>/qr/regenerate', methods=['POST'])
@login_required @login_required
@supervisor_required
def regenerate_qr(facility_id): 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 import secrets
facility = db.session.get(Facility, facility_id) facility = _facility_for_qr_or_403(facility_id)
if facility is None: if current_user.role not in ('admin', 'director', 'customer'):
abort(404) abort(403)
facility.qr_token = secrets.token_urlsafe(32) facility.qr_token = secrets.token_urlsafe(32)
db.session.commit() db.session.commit()
@@ -315,3 +377,231 @@ def regenerate_qr(facility_id):
flash('QR code regenerated. Previously printed posters no longer work — ' flash('QR code regenerated. Previously printed posters no longer work — '
'print and post the new code.', 'success') '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 `GET /f/<token>` — public, tokenized, NO login (same authorization model as
vendor work orders, rule 89: the unguessable token IS the credential). Shows a 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, * summary stats (90 days): completed inspections, average score,
resolved issues, last inspection date 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 import logging
from datetime import timedelta 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 flask_login import current_user
from sqlalchemy import func, or_ from sqlalchemy import func, or_
@@ -33,6 +40,8 @@ from app import db, limiter
from app.models.facility import Facility, Area from app.models.facility import Facility, Area
from app.models.inspection import Inspection from app.models.inspection import Inspection
from app.models.issue import Issue 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.sla import sla_status
from app.utils.scope import get_customer_scope, get_inspector_scope from app.utils.scope import get_customer_scope, get_inspector_scope
from app.utils.time_utils import now_eastern 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') 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): def _can_view_full(facility):
"""True when the logged-in scanner's role scope covers this facility.""" """True when the logged-in scanner's role scope covers this facility."""
@@ -57,27 +132,37 @@ def _can_view_full(facility):
return False return False
@bp.route('/<token>') def _build_snapshot(facility, area=None):
@limiter.limit('60 per hour') """Assemble the public snapshot for a facility, or for one area of it.
def scan(token):
facility = Facility.query.filter_by(qr_token=token).first()
if facility is None or not facility.active:
abort(404)
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() now = now_eastern()
d30 = now - timedelta(days=30) d30 = now - timedelta(days=30)
d60 = now - timedelta(days=60) d60 = now - timedelta(days=60)
d90 = now - timedelta(days=90) 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( completed = Inspection.query.filter(
Inspection.facility_id == facility.id, *insp_scope,
Inspection.status == 'completed', Inspection.status == 'completed',
) )
# ── Summary stats (90 days) ─────────────────────────────────────────── # ── Summary stats (90 days) ───────────────────────────────────────────
total_90 = completed.filter(Inspection.inspection_date >= d90).count() total_90 = completed.filter(Inspection.inspection_date >= d90).count()
avg_90 = db.session.query(func.avg(Inspection.overall_score)).filter( avg_90 = db.session.query(func.avg(Inspection.overall_score)).filter(
Inspection.facility_id == facility.id, *insp_scope,
Inspection.status == 'completed', Inspection.status == 'completed',
Inspection.inspection_date >= d90, Inspection.inspection_date >= d90,
Inspection.overall_score.isnot(None), Inspection.overall_score.isnot(None),
@@ -91,7 +176,7 @@ def scan(token):
# ── Score trend: last 30 days vs prior 30 (mirrors send_score_alerts) ─ # ── Score trend: last 30 days vs prior 30 (mirrors send_score_alerts) ─
def _avg_between(start, end): def _avg_between(start, end):
return db.session.query(func.avg(Inspection.overall_score)).filter( return db.session.query(func.avg(Inspection.overall_score)).filter(
Inspection.facility_id == facility.id, *insp_scope,
Inspection.status == 'completed', Inspection.status == 'completed',
Inspection.overall_score.isnot(None), Inspection.overall_score.isnot(None),
Inspection.inspection_date >= start, 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 if (avg_cur is not None and avg_prior is not None) else None
# ── Open issues: counts by severity + SLA state (counts only) ───────── # ── Open issues: 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( open_issues = issue_q.filter(
Issue.status.in_(('open', 'in_progress'))).all() Issue.status.in_(('open', 'in_progress'))).all()
severity_counts = {s: 0 for s in SEVERITY_ORDER} severity_counts = {s: 0 for s in SEVERITY_ORDER}
@@ -130,13 +210,9 @@ def scan(token):
Issue.resolved_at >= d90, Issue.resolved_at >= d90,
).count() ).count()
logger.info('FACILITY QR SCAN | facility_id=%s | authenticated=%s', return dict(
facility.id, current_user.is_authenticated)
return render_template(
'facility_qr/view.html',
token = token,
facility = facility, facility = facility,
area = area,
contract = facility.project, contract = facility.project,
total_90 = total_90, total_90 = total_90,
avg_90 = float(avg_90) if avg_90 is not None else None, 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_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, avg_prior = float(avg_prior) if avg_prior is not None else None,
open_total = len(open_issues), open_total = len(open_issues),
severity_counts = severity_counts, severity_counts = severity_counts,
severity_order = SEVERITY_ORDER, severity_order = SEVERITY_ORDER,
sla_at_risk = sla_at_risk, sla_at_risk = sla_at_risk,
sla_breached = sla_breached, 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']) @bp.route('/<token>/report', methods=['POST'])
@limiter.limit('5 per hour') @limiter.limit('5 per hour')
def report(token): def report(token):
@@ -165,42 +274,132 @@ def report(token):
No login required — the unguessable QR token is the sole authorization. No login required — the unguessable QR token is the sole authorization.
A honeypot field silently rejects bot submissions. Creates an Issue with A honeypot field silently rejects bot submissions. Creates an Issue with
reported_by=None so staff know it came from a public form. 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() facility = _facility_by_token_or_404(token)
if facility is None or not facility.active: form = PublicIssueReportForm()
abort(404)
# Honeypot — bots fill this field, humans leave it blank # 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) logger.warning('FACILITY QR REPORT | honeypot triggered | facility_id=%s', facility.id)
return redirect(url_for('facility_qr.scan', token=token) + '?reported=1') return redirect(url_for('facility_qr.scan', token=token) + '?reported=1')
description = request.form.get('description', '').strip() if not form.validate_on_submit():
severity = request.form.get('severity', 'medium') # 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: severity = form.severity.data or 'medium'
return redirect(url_for('facility_qr.scan', token=token))
if severity not in ('low', 'medium', 'high'): if severity not in ('low', 'medium', 'high'):
severity = 'medium' severity = 'medium'
photo_path, extra_photos = _save_report_photos(form.photos.data)
description = _build_report_description(form, '[Reported via facility QR code]')
issue = Issue( issue = Issue(
facility_id = facility.id, facility_id = facility.id,
area_id = None,
severity = severity, severity = severity,
description = description, description = description,
photo_path = photo_path,
mobile_photo_paths = extra_photos,
status = 'open', status = 'open',
reported_by = None, # anonymous public submission reported_by = None, # anonymous public submission
) )
db.session.add(issue) db.session.add(issue)
db.session.commit() db.session.commit()
logger.info('FACILITY QR REPORT | facility_id=%s issue_id=%s severity=%s', _photo_count = (1 if photo_path else 0) + (len(extra_photos) if extra_photos else 0)
facility.id, issue.id, severity) 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) # Notify staff via the notification matrix (same event as Issues → Create).
try: # NOTE: title/body are REQUIRED positional args. The phase38 call omitted
from app.utils.notifications import notify_by_matrix # them, so every public QR report raised TypeError into the except below and
notify_by_matrix('issue_created', issue_id=issue.id, facility_id=facility.id) # nobody was ever notified — see MT3_DEPLOY.md §1.
except Exception as exc: _snippet = form.description.data.strip()
logger.error('FACILITY QR REPORT | notify_failed | err=%s', exc) 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') 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 <i class="bi bi-graph-up-arrow"></i> Scorecard
</a> </a>
{% endif %} {% 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) }}" <a href="{{ url_for('facilities.qr_card', facility_id=facility.id) }}"
class="btn btn-outline-secondary"> class="btn btn-outline-secondary">
<i class="bi bi-qr-code"></i> QR Code <i class="bi bi-qr-code"></i> QR Code
</a> </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 %} {% endif %}
{% if current_user.role in ['admin', 'director'] %} {% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('facilities.edit_facility', facility_id=facility.id) }}" class="btn btn-outline-primary"> <a href="{{ url_for('facilities.edit_facility', facility_id=facility.id) }}" class="btn btn-outline-primary">
@@ -135,6 +139,12 @@
</td> </td>
<td>{{ area.inspections.count() }}</td> <td>{{ area.inspections.count() }}</td>
<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'] %} {% 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"> <a href="{{ url_for('facilities.edit_area', area_id=area.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i> <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> </a>
{% endif %} {% endif %}
{# ── Report a problem ── #} {% with action = url_for('facility_qr.report', token=token) %}
<div class="card shadow-sm mb-3"> {% include "facility_qr/_report_form.html" %}
<div class="card-header bg-white fw-semibold py-2"> {% endwith %}
<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>
<p class="text-center text-muted small mt-2 mb-1"> <p class="text-center text-muted small mt-2 mb-1">
{% if last_date %}Last inspected {{ last_date.strftime('%b %d, %Y') }} · {% endif %} {% if last_date %}Last inspected {{ last_date.strftime('%b %d, %Y') }} · {% endif %}
+30
View File
@@ -320,3 +320,33 @@ class SetPasswordForm(FlaskForm):
existing = User.query.filter_by(username=field.data.strip()).first() existing = User.query.filter_by(username=field.data.strip()).first()
if existing: 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
+92
View File
@@ -1590,3 +1590,95 @@ def generate_facility_summary_pdf(facility, days, start, now,
doc.build(story) doc.build(story)
return buf.getvalue() 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()
@@ -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/<qr_token>, 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"))