Jul 10 - Update facility's area with QR code
This commit is contained in:
@@ -226,6 +226,91 @@ def facility_qr_print_all():
|
||||
facilities=facilities,
|
||||
selected_contract=selected_contract)
|
||||
|
||||
# ── Public Area QR code ───────────────────────────────────────────────────────
|
||||
# Mirrors the facility QR routes above, but scoped to a single area. Customer
|
||||
# scope is enforced via the area's parent facility.
|
||||
|
||||
def _public_area_url(area):
|
||||
"""Absolute URL the area QR encodes — the login-free area summary page."""
|
||||
area.ensure_public_token()
|
||||
if not area.public_token:
|
||||
return None
|
||||
return url_for('public.area_summary',
|
||||
token=area.public_token, _external=True)
|
||||
|
||||
|
||||
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)
|
||||
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.public_token
|
||||
url = _public_area_url(area)
|
||||
if created:
|
||||
db.session.commit()
|
||||
|
||||
import io
|
||||
import qrcode
|
||||
img = qrcode.make(url, box_size=10, border=2)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
buf.seek(0)
|
||||
|
||||
from flask import Response
|
||||
return Response(buf.getvalue(), mimetype='image/png', headers={
|
||||
'Cache-Control': 'private, max-age=3600',
|
||||
})
|
||||
|
||||
|
||||
@bp.route('/areas/<int:area_id>/qr')
|
||||
@login_required
|
||||
def area_qr_page(area_id):
|
||||
"""Printable page: area name + facility + QR + public URL + instructions."""
|
||||
area = _area_for_qr_or_403(area_id)
|
||||
public_url = _public_area_url(area)
|
||||
db.session.commit() # persist token if it was just generated
|
||||
return render_template('facilities/area_qr.html',
|
||||
area=area, facility=area.facility,
|
||||
public_url=public_url)
|
||||
|
||||
|
||||
@bp.route('/areas/<int:area_id>/qr/regenerate', methods=['POST'])
|
||||
@login_required
|
||||
def area_qr_regenerate(area_id):
|
||||
"""Mint a NEW token for this area, invalidating any printed QR code.
|
||||
|
||||
Allowed for admin/director, and for customers on their own assigned
|
||||
facilities. Project managers and inspectors cannot regenerate.
|
||||
"""
|
||||
area = _area_for_qr_or_403(area_id)
|
||||
if current_user.role not in ('admin', 'director', 'customer'):
|
||||
abort(403)
|
||||
|
||||
area.public_token = Area.generate_public_token()
|
||||
db.session.commit()
|
||||
|
||||
logger.info('FACILITIES | area_qr_regenerate | user=%s | area_id=%s',
|
||||
current_user.username, area.id)
|
||||
log_action(ACTION_UPDATE, 'Area', area.id, area.name,
|
||||
'regenerated public QR token (old code invalidated)')
|
||||
flash('QR code regenerated. Any previously printed codes for this area no '
|
||||
'longer work — reprint and repost.', 'warning')
|
||||
return redirect(url_for('facilities.area_qr_page', area_id=area.id))
|
||||
|
||||
|
||||
@bp.route('/<int:facility_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
@@ -297,7 +382,8 @@ def create_area(facility_id):
|
||||
area_type=form.area_type.data,
|
||||
facility_id=facility.id
|
||||
)
|
||||
|
||||
area.ensure_public_token() # QR landing-page token
|
||||
|
||||
db.session.add(area)
|
||||
db.session.commit()
|
||||
logger.info('FACILITIES | create_area | user=%s | area_id=%s name=%r facility=%r',
|
||||
|
||||
@@ -170,6 +170,119 @@ def _build_summary(facility: Facility) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _area_by_token_or_404(token):
|
||||
"""Resolve an area (and its ACTIVE facility) from the area's public token."""
|
||||
if not token:
|
||||
abort(404)
|
||||
area = Area.query.filter_by(public_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_area_summary(area, facility) -> dict:
|
||||
"""Assemble the occupant-facing summary for a single area.
|
||||
|
||||
Occupant-safe (rule 74): same aggregate-only shape as the facility page,
|
||||
but every metric is scoped to this area's inspections/issues.
|
||||
"""
|
||||
aid = area.id
|
||||
now = now_eastern()
|
||||
cutoff_90 = now - timedelta(days=90)
|
||||
cutoff_30 = now - timedelta(days=30)
|
||||
cutoff_60 = now - timedelta(days=60)
|
||||
|
||||
last_insp = (
|
||||
Inspection.query
|
||||
.filter(Inspection.area_id == aid,
|
||||
Inspection.status == 'completed',
|
||||
Inspection.overall_score.isnot(None))
|
||||
.order_by(Inspection.inspection_date.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
def _avg_between(start, end=None):
|
||||
q = (db.session.query(func.avg(Inspection.overall_score))
|
||||
.filter(Inspection.area_id == aid,
|
||||
Inspection.status == 'completed',
|
||||
Inspection.overall_score.isnot(None),
|
||||
Inspection.inspection_date >= start))
|
||||
if end is not None:
|
||||
q = q.filter(Inspection.inspection_date < end)
|
||||
return q.scalar()
|
||||
|
||||
avg_90 = _avg_between(cutoff_90)
|
||||
if avg_90 is None:
|
||||
avg_90 = (
|
||||
db.session.query(func.avg(Inspection.overall_score))
|
||||
.filter(Inspection.area_id == aid,
|
||||
Inspection.status == 'completed',
|
||||
Inspection.overall_score.isnot(None))
|
||||
.scalar()
|
||||
)
|
||||
avg_score = round(float(avg_90), 1) if avg_90 is not None else None
|
||||
|
||||
inspections_90 = (
|
||||
Inspection.query
|
||||
.filter(Inspection.area_id == aid,
|
||||
Inspection.status == 'completed',
|
||||
Inspection.inspection_date >= cutoff_90)
|
||||
.count()
|
||||
)
|
||||
|
||||
avg_cur = _avg_between(cutoff_30)
|
||||
avg_prior = _avg_between(cutoff_60, cutoff_30)
|
||||
if avg_cur is not None and avg_prior is not None:
|
||||
trend_delta = round(float(avg_cur) - float(avg_prior), 1)
|
||||
else:
|
||||
trend_delta = None
|
||||
|
||||
recent = (
|
||||
Inspection.query
|
||||
.filter(Inspection.area_id == aid,
|
||||
Inspection.status == 'completed')
|
||||
.order_by(Inspection.inspection_date.desc())
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
recent_dates = [i.inspection_date for i in recent]
|
||||
|
||||
open_issue_count = (
|
||||
Issue.query
|
||||
.filter(Issue.status.in_(['open', 'in_progress']),
|
||||
Issue.area_id == aid)
|
||||
.count()
|
||||
)
|
||||
|
||||
resolved_90 = (
|
||||
Issue.query
|
||||
.filter(Issue.status == 'resolved',
|
||||
Issue.resolved_at.isnot(None),
|
||||
Issue.resolved_at >= cutoff_90,
|
||||
Issue.area_id == aid)
|
||||
.count()
|
||||
)
|
||||
|
||||
label, colour = _rating_label(avg_score)
|
||||
|
||||
return {
|
||||
'facility': facility,
|
||||
'area': area,
|
||||
'avg_score': avg_score,
|
||||
'rating_label': label,
|
||||
'rating_colour': colour,
|
||||
'inspections_90': inspections_90,
|
||||
'open_issue_count': open_issue_count,
|
||||
'resolved_90': resolved_90,
|
||||
'trend_delta': trend_delta,
|
||||
'last_inspected': last_insp.inspection_date if last_insp else None,
|
||||
'recent_dates': recent_dates,
|
||||
}
|
||||
|
||||
|
||||
@bp.route('/<token>', methods=['GET'])
|
||||
def facility_summary(token):
|
||||
facility = _facility_by_token_or_404(token)
|
||||
@@ -179,6 +292,15 @@ def facility_summary(token):
|
||||
form=form, token=token, **summary)
|
||||
|
||||
|
||||
@bp.route('/area/<token>', methods=['GET'])
|
||||
def area_summary(token):
|
||||
area, facility = _area_by_token_or_404(token)
|
||||
summary = _build_area_summary(area, facility)
|
||||
form = PublicIssueReportForm()
|
||||
return render_template('public/area.html',
|
||||
form=form, token=token, **summary)
|
||||
|
||||
|
||||
@bp.route('/<token>/report', methods=['POST'])
|
||||
@limiter.limit('5 per hour; 20 per day')
|
||||
def report_problem(token):
|
||||
@@ -248,3 +370,71 @@ def report_problem(token):
|
||||
flash('Thank you — your report has been received and the team has been notified.',
|
||||
'success')
|
||||
return redirect(url_for('public.facility_summary', token=token))
|
||||
|
||||
|
||||
@bp.route('/area/<token>/report', methods=['POST'])
|
||||
@limiter.limit('5 per hour; 20 per day')
|
||||
def area_report_problem(token):
|
||||
area, facility = _area_by_token_or_404(token)
|
||||
form = PublicIssueReportForm()
|
||||
|
||||
# Honeypot: silently accept-and-drop obvious bot submissions.
|
||||
if form.website.data:
|
||||
logger.info('PUBLIC REPORT | honeypot tripped | area_id=%s | ip=%s',
|
||||
area.id, request.remote_addr)
|
||||
flash('Thank you — your report has been received.', 'success')
|
||||
return redirect(url_for('public.area_summary', token=token))
|
||||
|
||||
if not form.validate_on_submit():
|
||||
summary = _build_area_summary(area, facility)
|
||||
return render_template('public/area.html',
|
||||
form=form, token=token, **summary), 400
|
||||
|
||||
from app.routes.inspections import _save_photo
|
||||
photo_path = _save_photo(form.photo.data, subfolder='issue_photos')
|
||||
|
||||
# The area is known from the QR token, so we set area_id directly and note
|
||||
# the source. A public reporter is not a User, so reported_by stays NULL.
|
||||
parts = [f'[Reported via area QR code — {area.name}]']
|
||||
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())
|
||||
description = '\n'.join(parts)
|
||||
|
||||
issue = Issue(
|
||||
facility_id = facility.id,
|
||||
area_id = area.id,
|
||||
severity = 'medium',
|
||||
description = description,
|
||||
photo_path = photo_path,
|
||||
status = 'open',
|
||||
reported_at = now_eastern(),
|
||||
reported_by = None,
|
||||
)
|
||||
db.session.add(issue)
|
||||
db.session.commit()
|
||||
|
||||
logger.info('PUBLIC REPORT | issue_id=%s | area_id=%s | facility_id=%s | ip=%s | photo=%s',
|
||||
issue.id, area.id, facility.id, request.remote_addr, bool(photo_path))
|
||||
|
||||
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: {form.description.data.strip()[:120]}'
|
||||
f'{"…" if len(form.description.data.strip()) > 120 else ""}'
|
||||
),
|
||||
link = url_for('issues.view', issue_id=issue.id),
|
||||
issue_id = issue.id,
|
||||
facility_id = facility.id,
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
flash('Thank you — your report has been received and the team has been notified.',
|
||||
'success')
|
||||
return redirect(url_for('public.area_summary', token=token))
|
||||
|
||||
Reference in New Issue
Block a user