Files
LT_Janitorial_Quality_Control/app/routes/public.py
T

469 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
app/routes/public.py
--------------------
Login-free, token-addressed facility pages reached by scanning a facility's
QR code. The QR encodes /f/<public_token> (an unguessable token, so the pages
cannot be enumerated by facility id).
Routes
------
GET /f/<token> Occupant-friendly facility summary (no login).
POST /f/<token>/report Occupant "report a problem" → creates an open Issue.
Design notes
------------
- Occupant-friendly: shows a quality rating, last-inspected date, and open-issue
COUNT only — never issue descriptions, inspector names, or internal scores.
- Inactive facilities 404 (a decommissioned QR reveals nothing).
- The report form is rate-limited and honeypot-guarded against bots, and reuses
the normal issue-creation notification path so staff/customers are alerted.
"""
import logging
from datetime import timedelta
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
from sqlalchemy import func
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.time_utils import now_eastern
from app.utils.notifications import notify_by_matrix
logger = logging.getLogger(__name__)
bp = Blueprint('public', __name__, url_prefix='/f')
#: 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`).
"""
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: str) -> Facility:
"""Resolve an ACTIVE facility from its public token, else 404."""
if not token:
abort(404)
facility = Facility.query.filter_by(public_token=token).first()
if facility is None or not facility.active:
abort(404)
return facility
def _rating_label(score):
"""Map a 0100 score to an occupant-friendly label + Bootstrap colour."""
if score is None:
return ('Not yet rated', 'secondary')
if score >= 90:
return ('Excellent', 'success')
if score >= 80:
return ('Good', 'success')
if score >= 70:
return ('Fair', 'warning')
return ('Needs attention', 'danger')
def _build_summary(facility: Facility) -> dict:
"""Assemble the occupant-facing summary for a facility.
Occupant-safe (rule 74): aggregate rating, counts, a score trend, and
recent inspection DATES only — no checklist/template names, no
per-inspection scores, no issue descriptions, and no severity/SLA detail.
"""
fid = facility.id
now = now_eastern()
cutoff_90 = now - timedelta(days=90)
cutoff_30 = now - timedelta(days=30)
cutoff_60 = now - timedelta(days=60)
# Most recent completed, scored inspection (for last-inspected date)
last_insp = (
Inspection.query
.filter(Inspection.facility_id == fid,
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.facility_id == fid,
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()
# Average score over the last 90 days (fallback: all-time) for the rating
avg_90 = _avg_between(cutoff_90)
if avg_90 is None:
avg_90 = (
db.session.query(func.avg(Inspection.overall_score))
.filter(Inspection.facility_id == fid,
Inspection.status == 'completed',
Inspection.overall_score.isnot(None))
.scalar()
)
avg_score = round(float(avg_90), 1) if avg_90 is not None else None
# Completed-inspection count over the last 90 days
inspections_90 = (
Inspection.query
.filter(Inspection.facility_id == fid,
Inspection.status == 'completed',
Inspection.inspection_date >= cutoff_90)
.count()
)
# Score trend: last 30 days vs the prior 30 days (aggregate only)
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 DATES only (no checklist names, no scores)
recent = (
Inspection.query
.filter(Inspection.facility_id == fid,
Inspection.status == 'completed')
.order_by(Inspection.inspection_date.desc())
.limit(5)
.all()
)
recent_dates = [i.inspection_date for i in recent]
# Open-issue COUNT (linked directly or via an area) — no details exposed
open_issue_count = (
Issue.query
.outerjoin(Area, Issue.area_id == Area.id)
.filter(Issue.status.in_(['open', 'in_progress']),
db.or_(Issue.facility_id == fid, Area.facility_id == fid))
.count()
)
# Resolved-issue COUNT over the last 90 days — no details exposed
resolved_90 = (
Issue.query
.outerjoin(Area, Issue.area_id == Area.id)
.filter(Issue.status == 'resolved',
Issue.resolved_at.isnot(None),
Issue.resolved_at >= cutoff_90,
db.or_(Issue.facility_id == fid, Area.facility_id == fid))
.count()
)
label, colour = _rating_label(avg_score)
return {
'facility': facility,
'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,
}
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)
summary = _build_summary(facility)
form = PublicIssueReportForm()
return render_template('public/facility.html',
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):
facility = _facility_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 | facility_id=%s | ip=%s',
facility.id, request.remote_addr)
flash('Thank you — your report has been received.', 'success')
return redirect(url_for('public.facility_summary', token=token))
if not form.validate_on_submit():
# Re-render the page with validation errors and the summary intact.
summary = _build_summary(facility)
return render_template('public/facility.html',
form=form, token=token, **summary), 400
# Save up to 5 optional photos through the shared, magic-byte-validated
# saver. First → photo_path, the rest → mobile_photo_paths.
photo_path, extra_photos = _save_report_photos(form.photos.data)
# Fold optional reporter identity + location into the description; the
# public reporter is not a User, so reported_by stays NULL.
parts = ['[Reported via facility QR code]']
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 = None,
severity = 'medium',
description = description,
photo_path = photo_path,
mobile_photo_paths = extra_photos,
status = 'open',
reported_at = now_eastern(),
reported_by = None,
)
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('PUBLIC REPORT | issue_id=%s | facility_id=%s | ip=%s | photos=%s',
issue.id, facility.id, request.remote_addr, _photo_count)
# Reuse the standard issue-created routing (staff + facility customers).
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: {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.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
# Save up to 5 optional photos (first → photo_path, rest → mobile_photo_paths).
photo_path, extra_photos = _save_report_photos(form.photos.data)
# 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,
mobile_photo_paths = extra_photos,
status = 'open',
reported_at = now_eastern(),
reported_by = None,
)
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('PUBLIC REPORT | issue_id=%s | area_id=%s | facility_id=%s | ip=%s | photos=%s',
issue.id, area.id, facility.id, request.remote_addr, _photo_count)
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))