Files
LT_Janitorial_Quality_Control/app/routes/public.py
T

535 lines
20 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
#: Window within which an identical public report is treated as a duplicate.
DUPLICATE_REPORT_WINDOW_SECONDS = 60
def _recent_duplicate_report(facility_id, area_id, description):
"""Return True if an identical public report was just filed.
Belt-and-suspenders against duplicate submissions (double-taps, JS-disabled
clients, retries): if a public issue (reported_by IS NULL) with the same
facility/area and identical description was created within the last
DUPLICATE_REPORT_WINDOW_SECONDS, treat this one as a duplicate and skip it.
"""
cutoff = now_eastern() - timedelta(seconds=DUPLICATE_REPORT_WINDOW_SECONDS)
q = Issue.query.filter(
Issue.reported_by.is_(None),
Issue.reported_at >= cutoff,
Issue.description == description,
)
if area_id is not None:
q = q.filter(Issue.area_id == area_id)
else:
q = q.filter(Issue.facility_id == facility_id, Issue.area_id.is_(None))
return db.session.query(q.exists()).scalar()
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 _recent_rows(inspections):
"""Shape recent completed inspections for the occupant page.
Each row carries the date and the occupant-friendly quality label/colour.
The raw score percentage is deliberately NOT included in the payload — the
occupant sees the quality label only, so the number cannot leak into the
rendered page. Also excludes inspector names, template/checklist names,
and any per-item detail (occupant-safe).
"""
rows = []
for i in inspections:
score = float(i.overall_score) if i.overall_score is not None else None
label, colour = _rating_label(score) # None -> 'Not yet rated'
rows.append({
'date': i.inspection_date,
'label': label,
'colour': colour,
})
return rows
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 inspections showing date + quality LABEL only (no score
percentage) — no checklist/template names, no inspector names, no issue
descriptions, and no per-item or 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 inspections: date + score + quality label (no checklist/template
# names, no inspector names, no per-item detail).
recent = (
Inspection.query
.filter(Inspection.facility_id == fid,
Inspection.status == 'completed')
.order_by(Inspection.inspection_date.desc())
.limit(5)
.all()
)
recent_inspections = _recent_rows(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_inspections': recent_inspections,
}
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_inspections = _recent_rows(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_inspections': recent_inspections,
}
@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
# 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)
# Server-side idempotency: silently accept an identical repeat as success
# without creating a second issue (or saving its photos).
if _recent_duplicate_report(facility.id, None, description):
logger.info('PUBLIC REPORT | duplicate suppressed | facility_id=%s | ip=%s',
facility.id, request.remote_addr)
flash('Thank you — your report has been received and the team has been notified.',
'success')
return redirect(url_for('public.facility_summary', token=token))
# 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)
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
# 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)
# Server-side idempotency: silently accept an identical repeat as success
# without creating a second issue (or saving its photos).
if _recent_duplicate_report(facility.id, area.id, description):
logger.info('PUBLIC REPORT | duplicate suppressed | area_id=%s | facility_id=%s | ip=%s',
area.id, facility.id, request.remote_addr)
flash('Thank you — your report has been received and the team has been notified.',
'success')
return redirect(url_for('public.area_summary', token=token))
# Save up to 5 optional photos (first → photo_path, rest → mobile_photo_paths).
photo_path, extra_photos = _save_report_photos(form.photos.data)
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))