Jul 16 - Fill the gaps between Single-tenant mode and Multi-tenant mode - MT3
This commit is contained in:
@@ -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
@@ -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
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
@@ -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 & 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 & 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 %}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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"))
|
||||
Reference in New Issue
Block a user