Jul 10 - Update facility's area with QR code
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
> **Audience:** AI assistants and developers working on this codebase.
|
||||
> **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions.
|
||||
> **Last reviewed:** July 2026 (Phase 19 complete + mobile API gap-fill Phases A–E + customer UI refinements + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector performance Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + date/ID filters + Reports expansion Phases R1–R4 + Phase 24 issue_created notify defaults + Phase 25 inspection GPS + Phase 26 issue vendor fields + Phase 27 facility score alerts + Phase 28 inspection-notify fix + Phase 29 admin broadcasts + Phases 30–32 device registry consolidation + ProxyFix reverse-proxy fix + Phase 33 per-contract notification recipients + grouped Admin nav dropdown + forgot-password case-insensitive lookup & email normalization + transactional email sender/branding fix + Phase 34 facility QR public pages & report-a-problem + Phase 35 issue handler_type (our staff / facility / vendor) + Phase 36 scheduled inspections)
|
||||
> **Last reviewed:** July 2026 (Phase 19 complete + mobile API gap-fill Phases A–E + customer UI refinements + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector performance Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + date/ID filters + Reports expansion Phases R1–R4 + Phase 24 issue_created notify defaults + Phase 25 inspection GPS + Phase 26 issue vendor fields + Phase 27 facility score alerts + Phase 28 inspection-notify fix + Phase 29 admin broadcasts + Phases 30–32 device registry consolidation + ProxyFix reverse-proxy fix + Phase 33 per-contract notification recipients + grouped Admin nav dropdown + forgot-password case-insensitive lookup & email normalization + transactional email sender/branding fix + Phase 34 facility QR public pages & report-a-problem + Phase 35 issue handler_type (our staff / facility / vendor) + Phase 36 scheduled inspections + Phase 37 support chat persistence + Phase 38 support knowledge base + Phase 39 per-area QR public pages)
|
||||
|
||||
---
|
||||
|
||||
@@ -199,11 +199,14 @@ users: id, username (unique, indexed), full_name, email (unique, indexed),
|
||||
```
|
||||
facilities: id, name, address, contact_person, contact_phone, active, project_id (FK),
|
||||
public_token VARCHAR(48) unique ← Phase 34 (QR landing page)
|
||||
areas: id, facility_id (FK), name, area_type
|
||||
areas: id, facility_id (FK), name, area_type,
|
||||
public_token VARCHAR(48) unique ← Phase 39 (per-area QR landing page)
|
||||
```
|
||||
|
||||
**`public_token`** (Phase 34): unguessable per-facility token encoded in the facility's QR code. The QR points at `/f/<public_token>` — a **login-free** occupant summary page. `Facility.generate_public_token()` / `ensure_public_token()` mint one on demand; new facilities get one at creation, existing rows were backfilled by phase34. Rotating the token (regenerating it) invalidates any printed QR — intentional, for when a code is compromised.
|
||||
|
||||
**`Area.public_token`** (Phase 39): the same pattern applied per area. The QR points at `/f/area/<public_token>` — a **login-free** occupant summary scoped to that single area (its own avg score / inspection count / open-issue count / trend / recent inspection dates), with a "report a problem" form that files the issue with `area_id` set. `Area.generate_public_token()` / `ensure_public_token()` mirror the Facility methods; new areas get a token at creation, existing rows backfilled by phase39. Both public pages obey rule 74 (aggregate + dates only — never checklist names, per-inspection scores, or severity/SLA). Routing: `/f/area/<token>` and `/f/<token>` do not collide (tokens are single-segment; `area` is a literal first segment).
|
||||
|
||||
**`area_type` choices:** `restroom`, `lobby`, `hallway`, `office`, `kitchen`, `storage`, `floor`, `outdoor`, `other`
|
||||
|
||||
### Project / CustomerAssignment
|
||||
@@ -462,8 +465,8 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi
|
||||
|---|---|---|
|
||||
| `auth` | `/auth` | `/login`, `/logout`, `/profile`, `/users/*`, `/notification-matrix` |
|
||||
| `dashboard` | `/` | `GET /`, `/facility-trend` (AJAX) |
|
||||
| `facilities` | `/facilities` | CRUD + area management + QR code: `/<id>/qr` printable page, `/<id>/qr.png` image, `POST /<id>/qr/regenerate` (invalidates old printed code), `/qr/print-all[?contract_id=]` bulk sheet. **Customers may use all QR actions (including regenerate) for their own assigned facilities**; inspectors/PM/admin/director for any. Scope enforced by `_facility_for_qr_or_403()` (customers) / `get_customer_scope` (print-all). Regenerate is limited to admin/director + scoped customer (PM/inspector excluded). |
|
||||
| `public` | `/f` | **No login.** `GET /<token>` occupant facility summary; `POST /<token>/report` occupant issue report (rate-limited `5/hour`, honeypot). Resolves ACTIVE facility by `public_token` or 404. |
|
||||
| `facilities` | `/facilities` | CRUD + area management + QR code: `/<id>/qr` printable page, `/<id>/qr.png` image, `POST /<id>/qr/regenerate` (invalidates old printed code), `/qr/print-all[?contract_id=]` bulk sheet. **Per-area QR (Phase 39):** `/areas/<id>/qr`, `/areas/<id>/qr.png`, `POST /areas/<id>/qr/regenerate` — mirror the facility QR routes; scope enforced by `_area_for_qr_or_403()` via the area's parent facility. **Customers may use all QR actions (including regenerate) for their own assigned facilities**; inspectors/PM/admin/director for any. Scope enforced by `_facility_for_qr_or_403()` (customers) / `get_customer_scope` (print-all). Regenerate is limited to admin/director + scoped customer (PM/inspector excluded). |
|
||||
| `public` | `/f` | **No login.** `GET /<token>` occupant facility summary + `POST /<token>/report` occupant issue report; `GET /area/<token>` per-area summary + `POST /area/<token>/report` (Phase 39, files with `area_id` set). All report POSTs rate-limited `5/hour`, honeypot-guarded. Resolves ACTIVE facility (area's parent must be active) by `public_token` or 404. |
|
||||
| `projects` | `/projects` | CRUD + customer assignment management + notification-recipient add/remove (`/<id>/notify-recipients/add`, `/notify-recipients/<rid>/remove` — admin only) |
|
||||
| `customers` | `/customers` | list, invite, set-password, manage, import CSV |
|
||||
| `inspections` | `/inspections` | list, start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect, upload-photo (AJAX) |
|
||||
@@ -783,7 +786,8 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif
|
||||
→ phase35_issue_handler
|
||||
→ phase36_scheduled_insp
|
||||
→ phase37_support_chat
|
||||
→ phase38_support_knowledge ← HEAD
|
||||
→ phase38_support_knowledge
|
||||
→ phase39_area_public_token ← HEAD
|
||||
```
|
||||
|
||||
### phase21_performance_indexes
|
||||
@@ -922,6 +926,16 @@ flask db upgrade
|
||||
sudo systemctl restart gunicorn
|
||||
```
|
||||
|
||||
### phase39_area_public_token
|
||||
|
||||
Revision id `phase39_area_public_token`. Adds `areas.public_token VARCHAR(48)` (unguessable, unique), **backfills a token for every existing area** in the migration body, then creates the `uq_area_public_token` unique index. Backs the per-area public QR landing pages (see §5 `Area.public_token` and the `public` / `facilities` blueprint rows in §7). Mirrors phase34 exactly, one level down (area instead of facility). `INFORMATION_SCHEMA` checks — safe to re-run. No new dependency (`qrcode` + `Pillow` already present from phase34).
|
||||
|
||||
**Deploy order:**
|
||||
```bash
|
||||
flask db upgrade # adds + backfills areas.public_token
|
||||
sudo systemctl restart gunicorn
|
||||
```
|
||||
|
||||
**Deploy order for phases 24–32:**
|
||||
```bash
|
||||
flask db upgrade
|
||||
|
||||
@@ -50,9 +50,26 @@ class Area(db.Model):
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
area_type = db.Column(db.String(50))
|
||||
|
||||
# Phase 39: unguessable token encoded in the area's public QR code.
|
||||
# The QR points at /f/area/<public_token>, a login-free occupant summary
|
||||
# page scoped to this area. Backfilled for existing rows by phase39.
|
||||
public_token = db.Column(db.String(48), nullable=True, unique=True, index=True)
|
||||
|
||||
# Relationships
|
||||
inspections = db.relationship('Inspection', backref='area', lazy='dynamic')
|
||||
issues = db.relationship('Issue', backref='area', lazy='dynamic')
|
||||
|
||||
@staticmethod
|
||||
def generate_public_token() -> str:
|
||||
"""Return a fresh URL-safe token for the public QR link."""
|
||||
return secrets.token_urlsafe(24)
|
||||
|
||||
def ensure_public_token(self) -> str:
|
||||
"""Return this area's public_token, generating & persisting one if it
|
||||
is missing. The caller is responsible for db.session.commit()."""
|
||||
if not self.public_token:
|
||||
self.public_token = self.generate_public_token()
|
||||
return self.public_token
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Area {self.name}>'
|
||||
@@ -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,6 +382,7 @@ 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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}QR Code — {{ area.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
.navbar, nav, footer { display: none !important; }
|
||||
.qr-card { border: none !important; box-shadow: none !important; }
|
||||
}
|
||||
.qr-card { max-width: 520px; margin: 0 auto; }
|
||||
</style>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 no-print">
|
||||
<a href="{{ url_for('facilities.view_facility', facility_id=facility.id) }}"
|
||||
class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left"></i> Back to Facility
|
||||
</a>
|
||||
<div class="d-flex gap-2">
|
||||
{% if current_user.role in ['admin', 'director', 'customer'] %}
|
||||
<form method="POST"
|
||||
action="{{ url_for('facilities.area_qr_regenerate', area_id=area.id) }}"
|
||||
onsubmit="return confirm('Regenerate this QR code? Any codes already printed and posted for {{ area.name }} will STOP working and must be reprinted.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm"
|
||||
title="Invalidate the current code and generate a new one">
|
||||
<i class="bi bi-arrow-repeat"></i> Regenerate
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<button onclick="window.print()" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-printer"></i> Print
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm qr-card">
|
||||
<div class="card-body text-center p-4">
|
||||
<div class="text-muted text-uppercase small" style="letter-spacing:.08em;">
|
||||
Scan for Area Status
|
||||
</div>
|
||||
<h1 class="h3 fw-bold mt-1 mb-0">{{ area.name }}</h1>
|
||||
<div class="text-muted mb-3">{{ facility.name }}</div>
|
||||
|
||||
<img src="{{ url_for('facilities.area_qr_png', area_id=area.id) }}"
|
||||
alt="QR code for {{ area.name }}"
|
||||
style="width:260px;height:260px;max-width:100%;" class="mb-3">
|
||||
|
||||
<p class="mb-1">Scan this code with your phone camera to see this area's
|
||||
recent cleaning quality and to <strong>report a problem</strong>.</p>
|
||||
|
||||
{% if public_url %}
|
||||
<div class="small text-muted mt-3">
|
||||
Or visit:<br>
|
||||
<a href="{{ public_url }}" class="text-break">{{ public_url }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-muted small mt-3 no-print">
|
||||
Tip: print this and post it inside the area itself (e.g. on the restroom door).
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -135,6 +135,12 @@
|
||||
</td>
|
||||
<td>{{ area.inspections.count() }}</td>
|
||||
<td>
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager', 'customer'] %}
|
||||
<a href="{{ url_for('facilities.area_qr_page', area_id=area.id) }}"
|
||||
class="btn btn-sm btn-outline-dark" title="Printable QR code for this area">
|
||||
<i class="bi bi-qr-code"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<a href="{{ url_for('facilities.edit_area', area_id=area.id) }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-pencil"></i>
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>{{ area.name }} — {{ facility.name }}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background:#eef1f5; }
|
||||
.wrap { max-width: 640px; margin: 0 auto; padding: 16px; }
|
||||
.hpot { position:absolute !important; left:-9999px !important; width:1px; height:1px; overflow:hidden; }
|
||||
.card-soft { background:#fff; border:1px solid #e6e9ef; border-radius:.85rem; }
|
||||
.kpi-num { font-size: 1.9rem; font-weight: 700; line-height: 1; }
|
||||
.kpi-label { font-size: .68rem; letter-spacing:.05em; color:#8a93a2; text-transform:uppercase; }
|
||||
.kpi-sub { font-size: .62rem; letter-spacing:.05em; color:#aab1bd; text-transform:uppercase; }
|
||||
.fac-icon { width:40px; height:40px; border-radius:.6rem; background:#e7efff; color:#2563eb;
|
||||
display:flex; align-items:center; justify-content:center; font-size:1.3rem; flex:none; }
|
||||
.sec-title { font-weight:700; font-size:1.02rem; }
|
||||
.list-line { border-top:1px solid #eef0f4; }
|
||||
.list-line:first-child { border-top:0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'success' if category in ['success','info'] else category }} shadow-sm">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
{# ── Header ── #}
|
||||
<div class="d-flex align-items-start gap-3 mb-3 mt-1">
|
||||
<div class="fac-icon"><i class="bi bi-door-open"></i></div>
|
||||
<div>
|
||||
<h1 class="h4 fw-bold mb-1">{{ area.name }}</h1>
|
||||
<div class="text-muted small">
|
||||
{{ facility.name }}
|
||||
{% if area.area_type %} · {{ area.area_type|title }}{% endif %}
|
||||
{% if facility.address %}<br>{{ facility.address }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── KPI row ── #}
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card-soft p-3 text-center h-100">
|
||||
<div class="kpi-num text-{{ rating_colour }}">
|
||||
{{ ('%.1f' % avg_score) ~ '%' if avg_score is not none else '—' }}
|
||||
</div>
|
||||
<div class="kpi-label mt-2">Avg Score</div>
|
||||
<div class="kpi-sub">90 Days</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card-soft p-3 text-center h-100">
|
||||
<div class="kpi-num">{{ inspections_90 }}</div>
|
||||
<div class="kpi-label mt-2">Inspections</div>
|
||||
<div class="kpi-sub">90 Days</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card-soft p-3 text-center h-100">
|
||||
<div class="kpi-num">{{ open_issue_count }}</div>
|
||||
<div class="kpi-label mt-2">Open</div>
|
||||
<div class="kpi-sub">Issues</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card-soft p-3 text-center h-100">
|
||||
<div class="kpi-num">{{ resolved_90 }}</div>
|
||||
<div class="kpi-label mt-2">Resolved</div>
|
||||
<div class="kpi-sub">90 Days</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Score trend (aggregate only) ── #}
|
||||
<div class="card-soft p-3 mb-3 d-flex align-items-center justify-content-between">
|
||||
<div class="kpi-label">Score Trend — 30 Days vs Prior 30</div>
|
||||
<div>
|
||||
{% if trend_delta is none %}
|
||||
<span class="text-muted small">Not enough data yet</span>
|
||||
{% elif trend_delta > 0 %}
|
||||
<span class="text-success fw-semibold"><i class="bi bi-arrow-up-right"></i> +{{ '%.1f' % trend_delta }} pts</span>
|
||||
{% elif trend_delta < 0 %}
|
||||
<span class="text-danger fw-semibold"><i class="bi bi-arrow-down-right"></i> {{ '%.1f' % trend_delta }} pts</span>
|
||||
{% else %}
|
||||
<span class="text-muted fw-semibold"><i class="bi bi-dash"></i> No change</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Cleaning quality rating ── #}
|
||||
<div class="card-soft p-3 mb-3 d-flex align-items-center justify-content-between">
|
||||
<div class="sec-title"><i class="bi bi-stars me-1 text-{{ rating_colour }}"></i> Cleaning Quality</div>
|
||||
<span class="badge bg-{{ rating_colour }} fs-6">{{ rating_label }}</span>
|
||||
</div>
|
||||
|
||||
{# ── Recent inspections (DATES ONLY — occupant-safe) ── #}
|
||||
{% if recent_dates %}
|
||||
<div class="card-soft p-3 mb-3">
|
||||
<div class="sec-title mb-2"><i class="bi bi-clipboard-check me-1"></i> Recent Inspections</div>
|
||||
{% for d in recent_dates %}
|
||||
<div class="list-line py-2 d-flex align-items-center justify-content-between">
|
||||
<span>{{ d.strftime('%b %d, %Y') }}</span>
|
||||
<span class="text-success small"><i class="bi bi-check-circle-fill"></i> Completed</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="text-muted small mt-2">This area is inspected regularly by our quality team.</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Report a problem ── #}
|
||||
<div class="card-soft p-4 mb-4">
|
||||
<h2 class="sec-title"><i class="bi bi-megaphone me-1"></i> Report a Problem</h2>
|
||||
<p class="text-muted small">
|
||||
Notice something in <strong>{{ area.name }}</strong> that needs attention?
|
||||
Let the cleaning team know.
|
||||
</p>
|
||||
|
||||
<form method="POST"
|
||||
action="{{ url_for('public.area_report_problem', token=token) }}"
|
||||
enctype="multipart/form-data" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
{# Honeypot — hidden from humans; bots that fill it are rejected #}
|
||||
<div class="hpot" aria-hidden="true">
|
||||
{{ form.website.label }} {{ form.website(tabindex="-1", autocomplete="off") }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.area_label.label(class="form-label small fw-semibold") }}
|
||||
{{ form.area_label(class="form-control", placeholder="e.g. third stall from the door") }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.description.label(class="form-label small fw-semibold") }}
|
||||
{{ form.description(class="form-control", rows="4",
|
||||
placeholder="Describe what you noticed…") }}
|
||||
{% for e in form.description.errors %}
|
||||
<div class="text-danger small mt-1">{{ e }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-sm-6 mb-3">
|
||||
{{ form.reporter_name.label(class="form-label small fw-semibold") }}
|
||||
{{ form.reporter_name(class="form-control") }}
|
||||
</div>
|
||||
<div class="col-sm-6 mb-3">
|
||||
{{ form.reporter_contact.label(class="form-label small fw-semibold") }}
|
||||
{{ form.reporter_contact(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.photo.label(class="form-label small fw-semibold") }}
|
||||
{{ form.photo(class="form-control", accept="image/*") }}
|
||||
{% for e in form.photo.errors %}
|
||||
<div class="text-danger small mt-1">{{ e }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-send"></i> Submit Report
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="text-center text-muted small mb-4">
|
||||
Janitorial Quality Control
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,71 @@
|
||||
"""phase39 — areas.public_token for per-area public QR landing pages
|
||||
|
||||
Adds a unique, unguessable token per area. Each area's QR code encodes
|
||||
/f/area/<public_token>, an occupant-friendly summary scoped to that area plus
|
||||
a "report a problem" form (login-free). Existing areas are backfilled with a
|
||||
generated token.
|
||||
|
||||
Uses INFORMATION_SCHEMA checks — safe to re-run.
|
||||
"""
|
||||
|
||||
revision = 'phase39_area_public_token'
|
||||
down_revision = 'phase38_support_knowledge'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
import secrets
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def _column_exists(conn, table, column):
|
||||
result = conn.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() "
|
||||
"AND TABLE_NAME = :t AND COLUMN_NAME = :c"
|
||||
), {"t": table, "c": column})
|
||||
return result.scalar() > 0
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
|
||||
if not _column_exists(bind, 'areas', 'public_token'):
|
||||
op.execute(sa.text(
|
||||
"ALTER TABLE areas ADD COLUMN public_token VARCHAR(48) NULL"
|
||||
))
|
||||
|
||||
# Backfill a unique token for every existing area that lacks one.
|
||||
rows = bind.execute(sa.text(
|
||||
"SELECT id FROM areas WHERE public_token IS NULL OR public_token = ''"
|
||||
)).fetchall()
|
||||
for (aid,) in rows:
|
||||
token = secrets.token_urlsafe(24)
|
||||
bind.execute(
|
||||
sa.text("UPDATE areas SET public_token = :tok WHERE id = :id"),
|
||||
{"tok": token, "id": aid},
|
||||
)
|
||||
|
||||
# Enforce uniqueness now that every row has a value.
|
||||
existing_idx = bind.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'areas' "
|
||||
"AND INDEX_NAME = 'uq_area_public_token'"
|
||||
)).scalar()
|
||||
if not existing_idx:
|
||||
op.execute(sa.text(
|
||||
"CREATE UNIQUE INDEX uq_area_public_token ON areas (public_token)"
|
||||
))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
existing_idx = bind.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'areas' "
|
||||
"AND INDEX_NAME = 'uq_area_public_token'"
|
||||
)).scalar()
|
||||
if existing_idx:
|
||||
op.execute(sa.text("DROP INDEX uq_area_public_token ON areas"))
|
||||
if _column_exists(bind, 'areas', 'public_token'):
|
||||
op.execute(sa.text("ALTER TABLE areas DROP COLUMN public_token"))
|
||||
Reference in New Issue
Block a user