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

This commit is contained in:
2026-07-16 17:17:44 -04:00
parent a60afc6c41
commit 02b030b1d2
12 changed files with 1294 additions and 90 deletions
+303 -13
View File
@@ -6,7 +6,8 @@ from app.models.facility import Facility, Area
from app.models.project import Project
from app.utils.forms import FacilityForm, AreaForm
from app.utils.decorators import supervisor_required, admin_required, project_manager_required
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
from app.utils.audit import (log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE,
ACTION_EXPORT)
from app.utils.scope import get_customer_scope, get_inspector_scope
from app.tenancy.gates import quota_soft_check
@@ -244,20 +245,70 @@ def delete_area(area_id):
def _qr_scan_url(facility):
"""Absolute public scan URL, built from the current host (rule 64 pattern)."""
facility.ensure_qr_token()
return request.host_url.rstrip('/') + url_for('facility_qr.scan',
token=facility.qr_token)
def _facility_for_qr_or_403(facility_id):
"""Load a facility for a QR action, enforcing customer facility scope.
Customers may only touch QR codes for facilities they are assigned to; all
other roles have unrestricted QR access. Replaces the previous
@project_manager_required gate so a customer can print (and rotate) the
codes posted in their own building.
"""
facility = db.session.get(Facility, facility_id)
if facility is None:
abort(404)
# QR management is not an inspector task (matches qr_print_all/qr_export_pdf).
if current_user.role == 'inspector':
abort(403)
if current_user.role == 'customer':
cids = get_customer_scope(current_user) or []
if facility.id not in cids:
abort(403)
return facility
def _qr_png_bytes(url):
"""Return PNG bytes for a QR code encoding *url*."""
import io as _io
import qrcode
img = qrcode.make(url, box_size=10, border=2)
buf = _io.BytesIO()
img.save(buf, format='PNG')
return buf.getvalue()
@bp.route('/<int:facility_id>/qr.png')
@login_required
def facility_qr_png(facility_id):
"""Return the facility's QR code as a PNG image.
The printable card renders inline SVG; this PNG endpoint exists for the
print-all grid and is the same image the PDF export embeds.
"""
facility = _facility_for_qr_or_403(facility_id)
created = not facility.qr_token
url = _qr_scan_url(facility)
if created:
db.session.commit()
from flask import Response
return Response(_qr_png_bytes(url), mimetype='image/png', headers={
'Cache-Control': 'private, max-age=3600',
})
@bp.route('/<int:facility_id>/qr')
@login_required
@project_manager_required
def qr_card(facility_id):
"""Printable QR card for one facility. Generates the token on first use."""
from app.utils.qr import qr_svg
facility = db.session.get(Facility, facility_id)
if facility is None:
abort(404)
facility = _facility_for_qr_or_403(facility_id)
if not facility.qr_token:
facility.ensure_qr_token()
@@ -274,12 +325,20 @@ def qr_card(facility_id):
@bp.route('/qr-sheet')
@login_required
@project_manager_required
def qr_sheet():
"""Bulk print sheet — one labeled QR card per active facility."""
from app.utils.qr import qr_svg
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
if current_user.role == 'inspector':
abort(403)
if current_user.role == 'customer':
cids = get_customer_scope(current_user) or []
facilities = (Facility.query
.filter(Facility.id.in_(cids), Facility.active == True)
.order_by(Facility.name).all()) if cids else []
else:
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
generated = 0
for f in facilities:
@@ -297,14 +356,17 @@ def qr_sheet():
@bp.route('/<int:facility_id>/qr/regenerate', methods=['POST'])
@login_required
@supervisor_required
def regenerate_qr(facility_id):
"""Rotate the QR token — invalidates every previously printed poster."""
"""Rotate the QR token — invalidates every previously printed poster.
Allowed for admin/director, and for customers on their own assigned
facilities. Project managers, auditors and inspectors cannot regenerate.
"""
import secrets
facility = db.session.get(Facility, facility_id)
if facility is None:
abort(404)
facility = _facility_for_qr_or_403(facility_id)
if current_user.role not in ('admin', 'director', 'customer'):
abort(403)
facility.qr_token = secrets.token_urlsafe(32)
db.session.commit()
@@ -314,4 +376,232 @@ def regenerate_qr(facility_id):
'QR token regenerated — previously printed QR posters are now invalid')
flash('QR code regenerated. Previously printed posters no longer work — '
'print and post the new code.', 'success')
return redirect(url_for('facilities.qr_card', facility_id=facility_id))
return redirect(url_for('facilities.qr_card', facility_id=facility_id))
# ── Public Area QR codes (phase42) ────────────────────────────────────────────
# Mirrors the facility QR routes above, but scoped to a single area. Customer
# scope is enforced via the area's parent facility.
def _area_qr_scan_url(area):
"""Absolute public scan URL for an area, built from the current host."""
area.ensure_qr_token()
return request.host_url.rstrip('/') + url_for('facility_qr.area_scan',
token=area.qr_token)
def _area_for_qr_or_403(area_id):
"""Load an area for a QR action, enforcing customer facility scope."""
area = db.session.get(Area, area_id)
if area is None:
abort(404)
# QR management is not an inspector task (matches qr_print_all/qr_export_pdf).
if current_user.role == 'inspector':
abort(403)
if current_user.role == 'customer':
cids = get_customer_scope(current_user) or []
if area.facility_id not in cids:
abort(403)
return area
@bp.route('/areas/<int:area_id>/qr.png')
@login_required
def area_qr_png(area_id):
"""Return the area's QR code as a PNG image."""
area = _area_for_qr_or_403(area_id)
created = not area.qr_token
url = _area_qr_scan_url(area)
if created:
db.session.commit()
from flask import Response
return Response(_qr_png_bytes(url), mimetype='image/png', headers={
'Cache-Control': 'private, max-age=3600',
})
@bp.route('/areas/<int:area_id>/qr')
@login_required
def area_qr_card(area_id):
"""Printable page: area name + facility + QR + public URL + instructions."""
from app.utils.qr import qr_svg
area = _area_for_qr_or_403(area_id)
created = not area.qr_token
scan_url = _area_qr_scan_url(area)
if created:
db.session.commit()
logger.info('FACILITIES | area_qr_token_created | user=%s | area_id=%s',
current_user.username, area_id)
return render_template('facilities/area_qr.html',
area=area,
facility=area.facility,
scan_url=scan_url,
svg=qr_svg(scan_url))
@bp.route('/areas/<int:area_id>/qr/regenerate', methods=['POST'])
@login_required
def regenerate_area_qr(area_id):
"""Rotate an area's QR token — invalidates every previously printed poster.
Allowed for admin/director, and for customers on their own assigned
facilities. Project managers, auditors and inspectors cannot regenerate.
"""
import secrets
area = _area_for_qr_or_403(area_id)
if current_user.role not in ('admin', 'director', 'customer'):
abort(403)
area.qr_token = secrets.token_urlsafe(32)
db.session.commit()
logger.info('FACILITIES | area_qr_token_regenerated | user=%s | area_id=%s',
current_user.username, area_id)
log_action(ACTION_UPDATE, 'Area', area.id, area.name,
'QR token regenerated — previously printed QR posters are now invalid')
flash('QR code regenerated. Previously printed posters no longer work — '
'print and post the new code.', 'warning')
return redirect(url_for('facilities.area_qr_card', area_id=area.id))
# ── Bulk QR print / export (phase42) ──────────────────────────────────────────
@bp.route('/qr/print-all')
@login_required
def qr_print_all():
"""Printable / selectable sheet of the QR codes the user can see.
Query params (all optional):
?contract_id=<id> — limit to one contract; narrows the facility dropdown
?facility_id=<id> — limit to a single facility
?include_areas=1 — also render each facility's per-area QR codes
Inspectors have no QR management (403); customers are scoped to their
assigned facilities; managers see all active facilities.
"""
if current_user.role == 'inspector':
abort(403)
contract_id = request.args.get('contract_id', type=int)
facility_id = request.args.get('facility_id', type=int)
include_areas = request.args.get('include_areas') in ('1', 'true', 'on')
# Facilities in the viewer's scope.
if current_user.role == 'customer':
fids = get_customer_scope(current_user) or []
scoped = Facility.query.filter(Facility.id.in_(fids), Facility.active == True)
else:
scoped = Facility.query.filter(Facility.active == True)
scoped_facilities = scoped.order_by(Facility.name).all()
# Contract dropdown — only contracts present among the scoped facilities.
contract_ids = {f.project_id for f in scoped_facilities if f.project_id}
contracts = (Project.query
.filter(Project.id.in_(contract_ids))
.order_by(Project.name).all()) if contract_ids else []
# Facility dropdown — narrowed by the selected contract.
facility_options = [f for f in scoped_facilities
if not contract_id or f.project_id == contract_id]
# The rendered grid — apply the contract + facility filters.
grid_facilities = facility_options
if facility_id:
grid_facilities = [f for f in grid_facilities if f.id == facility_id]
# Ensure every rendered facility (and area, if requested) has a token so
# its qr.png renders; collect areas keyed by facility id.
changed = False
areas_by_facility = {}
for f in grid_facilities:
if not f.qr_token:
f.ensure_qr_token()
changed = True
if include_areas:
fa = f.areas.order_by(Area.name).all()
for a in fa:
if not a.qr_token:
a.ensure_qr_token()
changed = True
areas_by_facility[f.id] = fa
if changed:
db.session.commit()
selected_contract = db.session.get(Project, contract_id) if contract_id else None
return render_template('facilities/qr_print_all.html',
facilities=grid_facilities,
areas_by_facility=areas_by_facility,
include_areas=include_areas,
contracts=contracts,
facility_options=facility_options,
selected_contract=selected_contract,
selected_contract_id=contract_id,
selected_facility_id=facility_id)
@bp.route('/qr/export-pdf', methods=['POST'])
@login_required
def qr_export_pdf():
"""Export the selected facility + area QR codes to a single PDF.
Selection arrives as repeated `facility_ids` / `area_ids` form fields.
Scope is enforced per-id via the same helpers as the QR pages, so a
customer can never export a code outside their assigned facilities.
"""
if current_user.role == 'inspector':
abort(403)
facility_ids = request.form.getlist('facility_ids', type=int)
area_ids = request.form.getlist('area_ids', type=int)
if not facility_ids and not area_ids:
flash('Select at least one QR code to export.', 'warning')
return redirect(request.referrer or url_for('facilities.qr_print_all'))
items = []
for fid in facility_ids:
facility = _facility_for_qr_or_403(fid) # 403 if out of scope
url = _qr_scan_url(facility)
items.append({
'title': facility.name,
'subtitle': facility.project.name if facility.project else None,
'caption': 'Facility · Report a problem & view recent quality',
'png': _qr_png_bytes(url),
'_sort': ((facility.name or '').lower(), 0, ''),
})
for aid in area_ids:
area = _area_for_qr_or_403(aid) # 403 if out of scope
url = _area_qr_scan_url(area)
fac_name = area.facility.name if area.facility else ''
items.append({
'title': area.name,
'subtitle': fac_name or None,
'caption': 'Area · Report a problem & view recent quality',
'png': _qr_png_bytes(url),
'_sort': (fac_name.lower(), 1, (area.name or '').lower()),
})
# Persist any tokens minted by ensure_qr_token() above.
db.session.commit()
# Group each facility with its own areas: facility card first, then areas.
items.sort(key=lambda x: x['_sort'])
from app.utils.pdf_export import generate_qr_codes_pdf
summary = f'{len(facility_ids)} facilit' + ('y' if len(facility_ids) == 1 else 'ies')
summary += f', {len(area_ids)} area' + ('' if len(area_ids) == 1 else 's')
pdf_bytes = generate_qr_codes_pdf(items, filter_summary=summary)
logger.info('FACILITIES | qr_export_pdf | user=%s | facilities=%s | areas=%s',
current_user.username, len(facility_ids), len(area_ids))
log_action(ACTION_EXPORT, 'Facility', 0, 'QR Codes',
f'exported {len(facility_ids)} facility + {len(area_ids)} area QR codes to PDF')
from flask import Response
return Response(pdf_bytes, mimetype='application/pdf', headers={
'Content-Disposition': 'attachment; filename="qr_codes.pdf"',
})