Jul 7 - Implement QR codes for facility
This commit is contained in:
@@ -5,7 +5,7 @@ from app import db
|
||||
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
|
||||
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.scope import get_customer_scope, get_inspector_scope
|
||||
from app.tenancy.gates import quota_soft_check
|
||||
@@ -237,4 +237,81 @@ def delete_area(area_id):
|
||||
current_user.username, area_id_snap, area_name)
|
||||
log_action(ACTION_DELETE, 'Area', area_id_snap, area_name)
|
||||
flash(f'Area "{area_name}" deleted successfully.', 'success')
|
||||
return redirect(url_for('facilities.view_facility', facility_id=facility_id))
|
||||
return redirect(url_for('facilities.view_facility', facility_id=facility_id))
|
||||
|
||||
|
||||
# ── Facility QR codes (phase38) ───────────────────────────────────────────────
|
||||
|
||||
def _qr_scan_url(facility):
|
||||
"""Absolute public scan URL, built from the current host (rule 64 pattern)."""
|
||||
return request.host_url.rstrip('/') + url_for('facility_qr.scan',
|
||||
token=facility.qr_token)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
if not facility.qr_token:
|
||||
facility.ensure_qr_token()
|
||||
db.session.commit()
|
||||
logger.info('FACILITIES | qr_token_created | user=%s | facility_id=%s',
|
||||
current_user.username, facility_id)
|
||||
|
||||
scan_url = _qr_scan_url(facility)
|
||||
return render_template('facilities/qr_card.html',
|
||||
facility=facility,
|
||||
scan_url=scan_url,
|
||||
svg=qr_svg(scan_url))
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
generated = 0
|
||||
for f in facilities:
|
||||
if not f.qr_token:
|
||||
f.ensure_qr_token()
|
||||
generated += 1
|
||||
if generated:
|
||||
db.session.commit()
|
||||
logger.info('FACILITIES | qr_tokens_created | user=%s | count=%s',
|
||||
current_user.username, generated)
|
||||
|
||||
cards = [{'facility': f, 'svg': qr_svg(_qr_scan_url(f))} for f in facilities]
|
||||
return render_template('facilities/qr_sheet.html', cards=cards)
|
||||
|
||||
|
||||
@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."""
|
||||
import secrets
|
||||
|
||||
facility = db.session.get(Facility, facility_id)
|
||||
if facility is None:
|
||||
abort(404)
|
||||
|
||||
facility.qr_token = secrets.token_urlsafe(32)
|
||||
db.session.commit()
|
||||
logger.info('FACILITIES | qr_token_regenerated | user=%s | facility_id=%s',
|
||||
current_user.username, facility_id)
|
||||
log_action(ACTION_UPDATE, 'Facility', facility.id, facility.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.', 'success')
|
||||
return redirect(url_for('facilities.qr_card', facility_id=facility_id))
|
||||
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
app/routes/facility_qr.py
|
||||
-------------------------
|
||||
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:
|
||||
|
||||
* summary stats (90 days): completed inspections, average score,
|
||||
resolved issues, last inspection date
|
||||
* score trend: last-30-day average vs the prior 30 days (same math as the
|
||||
score-drop alert in sla.py)
|
||||
* recent completed inspections: date, template, score — NO inspector names
|
||||
* open issues: counts by severity + SLA at-risk / breached counts —
|
||||
NO descriptions, NO photos
|
||||
|
||||
Hybrid access: if the scanner is logged in AND their role scope covers this
|
||||
facility, a button links to the full internal facility view.
|
||||
|
||||
In multi-tenant mode the printed URL is built from the tenant's own domain
|
||||
(request.host_url), so the route resolves by Host — NOT tenant-exempt.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from flask import Blueprint, render_template, abort
|
||||
from flask_login import current_user
|
||||
from sqlalchemy import func, or_
|
||||
|
||||
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.sla import sla_status
|
||||
from app.utils.scope import get_customer_scope, get_inspector_scope
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('facility_qr', __name__, url_prefix='/f')
|
||||
|
||||
SEVERITY_ORDER = ('critical', 'high', 'medium', 'low')
|
||||
|
||||
|
||||
def _can_view_full(facility):
|
||||
"""True when the logged-in scanner's role scope covers this facility."""
|
||||
if not current_user.is_authenticated:
|
||||
return False
|
||||
if current_user.role in ('admin', 'director', 'project_manager'):
|
||||
return True
|
||||
if current_user.role == 'inspector':
|
||||
return facility.id in (get_inspector_scope(current_user) or [])
|
||||
if current_user.role == 'customer':
|
||||
return facility.id in (get_customer_scope(current_user) or [])
|
||||
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)
|
||||
|
||||
now = now_eastern()
|
||||
d30 = now - timedelta(days=30)
|
||||
d60 = now - timedelta(days=60)
|
||||
d90 = now - timedelta(days=90)
|
||||
|
||||
completed = Inspection.query.filter(
|
||||
Inspection.facility_id == facility.id,
|
||||
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,
|
||||
Inspection.status == 'completed',
|
||||
Inspection.inspection_date >= d90,
|
||||
Inspection.overall_score.isnot(None),
|
||||
).scalar()
|
||||
|
||||
recent = (completed
|
||||
.order_by(Inspection.inspection_date.desc())
|
||||
.limit(8).all())
|
||||
last_date = recent[0].inspection_date if recent else None
|
||||
|
||||
# ── 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,
|
||||
Inspection.status == 'completed',
|
||||
Inspection.overall_score.isnot(None),
|
||||
Inspection.inspection_date >= start,
|
||||
Inspection.inspection_date < end,
|
||||
).scalar()
|
||||
|
||||
avg_cur = _avg_between(d30, now + timedelta(days=1))
|
||||
avg_prior = _avg_between(d60, d30)
|
||||
trend_delta = (float(avg_cur) - float(avg_prior)) \
|
||||
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}
|
||||
sla_at_risk = sla_breached = 0
|
||||
for issue in open_issues:
|
||||
if issue.severity in severity_counts:
|
||||
severity_counts[issue.severity] += 1
|
||||
state = sla_status(issue)
|
||||
if state == 'at_risk':
|
||||
sla_at_risk += 1
|
||||
elif state == 'breached':
|
||||
sla_breached += 1
|
||||
|
||||
pending_verification = issue_q.filter(
|
||||
Issue.status == 'pending_verification').count()
|
||||
resolved_90 = issue_q.filter(
|
||||
Issue.status == 'resolved',
|
||||
Issue.resolved_at.isnot(None),
|
||||
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',
|
||||
facility = facility,
|
||||
contract = facility.project,
|
||||
total_90 = total_90,
|
||||
avg_90 = float(avg_90) if avg_90 is not None else None,
|
||||
last_date = last_date,
|
||||
recent = recent,
|
||||
trend_delta = trend_delta,
|
||||
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_order = SEVERITY_ORDER,
|
||||
sla_at_risk = sla_at_risk,
|
||||
sla_breached = sla_breached,
|
||||
pending_verification = pending_verification,
|
||||
resolved_90 = resolved_90,
|
||||
can_view_full = _can_view_full(facility),
|
||||
generated_at = now,
|
||||
)
|
||||
Reference in New Issue
Block a user