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