Jul 8 - Implement QR code per facility
This commit is contained in:
@@ -197,10 +197,13 @@ users: id, username (unique, indexed), full_name, email (unique, indexed),
|
|||||||
### Facility / Area
|
### Facility / Area
|
||||||
|
|
||||||
```
|
```
|
||||||
facilities: id, name, address, contact_person, contact_phone, active, project_id (FK)
|
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`** (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_type` choices:** `restroom`, `lobby`, `hallway`, `office`, `kitchen`, `storage`, `floor`, `outdoor`, `other`
|
**`area_type` choices:** `restroom`, `lobby`, `hallway`, `office`, `kitchen`, `storage`, `floor`, `outdoor`, `other`
|
||||||
|
|
||||||
### Project / CustomerAssignment
|
### Project / CustomerAssignment
|
||||||
@@ -397,7 +400,8 @@ contract_notification_recipients:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `auth` | `/auth` | `/login`, `/logout`, `/profile`, `/users/*`, `/notification-matrix` |
|
| `auth` | `/auth` | `/login`, `/logout`, `/profile`, `/users/*`, `/notification-matrix` |
|
||||||
| `dashboard` | `/` | `GET /`, `/facility-trend` (AJAX) |
|
| `dashboard` | `/` | `GET /`, `/facility-trend` (AJAX) |
|
||||||
| `facilities` | `/facilities` | CRUD + area management |
|
| `facilities` | `/facilities` | CRUD + area management + QR code (`/<id>/qr` printable page, `/<id>/qr.png` image — staff only, customers 403) |
|
||||||
|
| `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. |
|
||||||
| `projects` | `/projects` | CRUD + customer assignment management + notification-recipient add/remove (`/<id>/notify-recipients/add`, `/notify-recipients/<rid>/remove` — admin only) |
|
| `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 |
|
| `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) |
|
| `inspections` | `/inspections` | list, start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect, upload-photo (AJAX) |
|
||||||
@@ -709,7 +713,8 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif
|
|||||||
→ phase30_device_registry
|
→ phase30_device_registry
|
||||||
→ phase31_device_registry
|
→ phase31_device_registry
|
||||||
→ phase32_device_token_columns
|
→ phase32_device_token_columns
|
||||||
→ phase33_contract_recipients ← HEAD
|
→ phase33_contract_recipients
|
||||||
|
→ phase34_facility_qr ← HEAD
|
||||||
```
|
```
|
||||||
|
|
||||||
### phase21_performance_indexes
|
### phase21_performance_indexes
|
||||||
@@ -794,6 +799,17 @@ flask db upgrade
|
|||||||
sudo systemctl restart gunicorn
|
sudo systemctl restart gunicorn
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### phase34_facility_qr
|
||||||
|
|
||||||
|
Revision id `phase34_facility_qr` (file `phase34_facility_public_token.py`). Adds `facilities.public_token VARCHAR(48)` (unguessable, unique) and **backfills a token for every existing facility** in the migration body, then creates the `uq_facility_public_token` unique index. Backs the public QR landing pages (see §5 `Facility.public_token` and the Public Facility QR section in §7). Uses `INFORMATION_SCHEMA` checks — safe to re-run.
|
||||||
|
|
||||||
|
**Deploy order:**
|
||||||
|
```bash
|
||||||
|
pip install qrcode # new dependency (Pillow already present)
|
||||||
|
flask db upgrade # adds + backfills public_token
|
||||||
|
sudo systemctl restart gunicorn
|
||||||
|
```
|
||||||
|
|
||||||
**Deploy order for phases 24–32:**
|
**Deploy order for phases 24–32:**
|
||||||
```bash
|
```bash
|
||||||
flask db upgrade
|
flask db upgrade
|
||||||
@@ -1122,6 +1138,7 @@ timeout = 30
|
|||||||
| 71 | **`ProxyFix` must wrap `app.wsgi_app` in `create_app()`** | Behind Nginx, `remote_addr` is `127.0.0.1` for every request without it, collapsing all Flask-Limiter keys into one bucket (global instead of per-client rate limiting). `x_for=1` trusts exactly one proxy hop. See §19. |
|
| 71 | **`ProxyFix` must wrap `app.wsgi_app` in `create_app()`** | Behind Nginx, `remote_addr` is `127.0.0.1` for every request without it, collapsing all Flask-Limiter keys into one bucket (global instead of per-client rate limiting). `x_for=1` trusts exactly one proxy hop. See §19. |
|
||||||
| 72 | **Device registration has exactly ONE implementation — `register_device()` in `app/api/auth.py` → `api_device_tokens`** | A second `POST /api/v1/devices/register` (`app/api/devices.py` + `DeviceRegistration` model) was removed July 2026. It was shadowed by the `api_auth` route at routing time and queried the dropped `device_registrations` table. Do not reintroduce a competing device model or duplicate register route. |
|
| 72 | **Device registration has exactly ONE implementation — `register_device()` in `app/api/auth.py` → `api_device_tokens`** | A second `POST /api/v1/devices/register` (`app/api/devices.py` + `DeviceRegistration` model) was removed July 2026. It was shadowed by the `api_auth` route at routing time and queried the dropped `device_registrations` table. Do not reintroduce a competing device model or duplicate register route. |
|
||||||
| 73 | **Per-contract recipients are dispatched ONLY inside `notify_by_matrix()` — never add a parallel path** | `_notify_contract_recipients()` runs after role + global-custom-email routing and shares the `notified` / `sent_emails` dedup sets. Any new event that should reach contract recipients must go through `notify_by_matrix()` (passing `facility_id`, or an `issue_id`/`inspection_id` that resolves to one). Bypassing it means contract recipients are silently skipped and dedup breaks. Commit stays the caller's responsibility. |
|
| 73 | **Per-contract recipients are dispatched ONLY inside `notify_by_matrix()` — never add a parallel path** | `_notify_contract_recipients()` runs after role + global-custom-email routing and shares the `notified` / `sent_emails` dedup sets. Any new event that should reach contract recipients must go through `notify_by_matrix()` (passing `facility_id`, or an `issue_id`/`inspection_id` that resolves to one). Bypassing it means contract recipients are silently skipped and dedup breaks. Commit stays the caller's responsibility. |
|
||||||
|
| 74 | **The `public` blueprint (`/f/*`) is login-free — keep it occupant-safe** | Pages are addressed by unguessable `public_token` (never facility id), 404 on inactive/unknown facilities, and expose only a quality rating, last-inspected date, and open-issue COUNT — **never** issue descriptions, inspector names, per-item scores, or any other facility's data. The `report` POST must stay CSRF-protected (Flask-WTF form), rate-limited, and honeypot-guarded; public-reported issues are created with `reported_by=NULL`, `severity='medium'`, and routed through `notify_by_matrix('issue_created', facility_id=...)`. Do not add fields that leak internal detail, and do not reuse `render_template('base.html')` here — the public page is a standalone template with no authenticated nav. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ def create_app(config_name='default'):
|
|||||||
from app.routes import support # Support chat + admin tickets
|
from app.routes import support # Support chat + admin tickets
|
||||||
from app.routes import broadcast # Admin broadcast notifications
|
from app.routes import broadcast # Admin broadcast notifications
|
||||||
from app.routes import devices # Admin device registry
|
from app.routes import devices # Admin device registry
|
||||||
|
from app.routes import public # Public facility QR pages (no login)
|
||||||
|
|
||||||
app.register_blueprint(auth.bp)
|
app.register_blueprint(auth.bp)
|
||||||
app.register_blueprint(dashboard.bp)
|
app.register_blueprint(dashboard.bp)
|
||||||
@@ -192,6 +193,7 @@ def create_app(config_name='default'):
|
|||||||
app.register_blueprint(support.bp)
|
app.register_blueprint(support.bp)
|
||||||
app.register_blueprint(broadcast.bp)
|
app.register_blueprint(broadcast.bp)
|
||||||
app.register_blueprint(devices.bp)
|
app.register_blueprint(devices.bp)
|
||||||
|
app.register_blueprint(public.bp)
|
||||||
|
|
||||||
# ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ───────────────────
|
# ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ───────────────────
|
||||||
# The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed.
|
# The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import secrets
|
||||||
from app import db
|
from app import db
|
||||||
from app.utils.time_utils import now_eastern
|
from app.utils.time_utils import now_eastern
|
||||||
|
|
||||||
@@ -16,10 +17,28 @@ class Facility(db.Model):
|
|||||||
project_id = db.Column(db.Integer, db.ForeignKey('projects.id', ondelete='SET NULL'),
|
project_id = db.Column(db.Integer, db.ForeignKey('projects.id', ondelete='SET NULL'),
|
||||||
nullable=True, index=True)
|
nullable=True, index=True)
|
||||||
|
|
||||||
|
# Phase 34: unguessable token encoded in the facility's public QR code.
|
||||||
|
# The QR points at /f/<public_token>, a login-free occupant summary page.
|
||||||
|
# Backfilled for existing rows by phase34; set at creation for new rows.
|
||||||
|
public_token = db.Column(db.String(48), nullable=True, unique=True, index=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
areas = db.relationship('Area', backref='facility', lazy='dynamic')
|
areas = db.relationship('Area', backref='facility', lazy='dynamic')
|
||||||
inspections = db.relationship('Inspection', backref='facility', lazy='dynamic')
|
inspections = db.relationship('Inspection', backref='facility', 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 facility's public_token, generating & persisting one
|
||||||
|
if it is missing (e.g. a row created before phase34 ran). 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):
|
def __repr__(self):
|
||||||
return f'<Facility {self.name}>'
|
return f'<Facility {self.name}>'
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ def create_facility():
|
|||||||
active=form.active.data
|
active=form.active.data
|
||||||
)
|
)
|
||||||
|
|
||||||
|
facility.ensure_public_token() # QR landing-page token
|
||||||
db.session.add(facility)
|
db.session.add(facility)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
logger.info('FACILITIES | create | user=%s | facility_id=%s name=%r',
|
logger.info('FACILITIES | create | user=%s | facility_id=%s name=%r',
|
||||||
@@ -95,6 +96,61 @@ def view_facility(facility_id):
|
|||||||
areas = facility.areas.order_by(Area.name).all()
|
areas = facility.areas.order_by(Area.name).all()
|
||||||
return render_template('facilities/view.html', facility=facility, areas=areas)
|
return render_template('facilities/view.html', facility=facility, areas=areas)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public QR code (staff-only generation) ────────────────────────────────────
|
||||||
|
|
||||||
|
def _public_facility_url(facility):
|
||||||
|
"""Absolute URL the QR encodes — the login-free occupant summary page."""
|
||||||
|
facility.ensure_public_token()
|
||||||
|
if not facility.public_token:
|
||||||
|
return None
|
||||||
|
return url_for('public.facility_summary',
|
||||||
|
token=facility.public_token, _external=True)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:facility_id>/qr.png')
|
||||||
|
@login_required
|
||||||
|
def facility_qr_png(facility_id):
|
||||||
|
"""Return the facility's QR code as a PNG image (staff only)."""
|
||||||
|
if current_user.role == 'customer':
|
||||||
|
abort(403)
|
||||||
|
facility = db.session.get(Facility, facility_id)
|
||||||
|
if facility is None:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
# Token may not exist for pre-phase34 rows viewed before any commit.
|
||||||
|
created = not facility.public_token
|
||||||
|
url = _public_facility_url(facility)
|
||||||
|
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('/<int:facility_id>/qr')
|
||||||
|
@login_required
|
||||||
|
def facility_qr_page(facility_id):
|
||||||
|
"""Printable page: facility name + QR + public URL + posting instructions."""
|
||||||
|
if current_user.role == 'customer':
|
||||||
|
abort(403)
|
||||||
|
facility = db.session.get(Facility, facility_id)
|
||||||
|
if facility is None:
|
||||||
|
abort(404)
|
||||||
|
public_url = _public_facility_url(facility)
|
||||||
|
db.session.commit() # persist token if it was just generated
|
||||||
|
return render_template('facilities/qr.html',
|
||||||
|
facility=facility, public_url=public_url)
|
||||||
|
|
||||||
@bp.route('/<int:facility_id>/edit', methods=['GET', 'POST'])
|
@bp.route('/<int:facility_id>/edit', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@supervisor_required
|
@supervisor_required
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""
|
||||||
|
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')
|
||||||
|
|
||||||
|
|
||||||
|
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 0–100 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."""
|
||||||
|
fid = facility.id
|
||||||
|
now = now_eastern()
|
||||||
|
cutoff = now - timedelta(days=90)
|
||||||
|
|
||||||
|
# Most recent completed, scored inspection
|
||||||
|
last_insp = (
|
||||||
|
Inspection.query
|
||||||
|
.filter(Inspection.facility_id == fid,
|
||||||
|
Inspection.status == 'completed',
|
||||||
|
Inspection.overall_score.isnot(None))
|
||||||
|
.order_by(Inspection.inspection_date.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Average score over the last 90 days (fallback: all-time) for the rating
|
||||||
|
avg_90 = (
|
||||||
|
db.session.query(func.avg(Inspection.overall_score))
|
||||||
|
.filter(Inspection.facility_id == fid,
|
||||||
|
Inspection.status == 'completed',
|
||||||
|
Inspection.overall_score.isnot(None),
|
||||||
|
Inspection.inspection_date >= cutoff)
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
)
|
||||||
|
|
||||||
|
label, colour = _rating_label(avg_score)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'facility': facility,
|
||||||
|
'avg_score': avg_score,
|
||||||
|
'rating_label': label,
|
||||||
|
'rating_colour': colour,
|
||||||
|
'last_inspected': last_insp.inspection_date if last_insp else None,
|
||||||
|
'last_score': (round(float(last_insp.overall_score), 1)
|
||||||
|
if last_insp and last_insp.overall_score is not None else None),
|
||||||
|
'open_issue_count': open_issue_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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('/<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 optional photo through the shared, magic-byte-validated saver.
|
||||||
|
from app.routes.inspections import _save_photo
|
||||||
|
photo_path = _save_photo(form.photo.data, subfolder='issue_photos')
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
status = 'open',
|
||||||
|
reported_at = now_eastern(),
|
||||||
|
reported_by = None,
|
||||||
|
)
|
||||||
|
db.session.add(issue)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info('PUBLIC REPORT | issue_id=%s | facility_id=%s | ip=%s | photo=%s',
|
||||||
|
issue.id, facility.id, request.remote_addr, bool(photo_path))
|
||||||
|
|
||||||
|
# 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))
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}QR Code — {{ facility.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>
|
||||||
|
<button onclick="window.print()" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-printer"></i> Print
|
||||||
|
</button>
|
||||||
|
</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 Facility Status
|
||||||
|
</div>
|
||||||
|
<h1 class="h3 fw-bold mt-1 mb-3">{{ facility.name }}</h1>
|
||||||
|
|
||||||
|
<img src="{{ url_for('facilities.facility_qr_png', facility_id=facility.id) }}"
|
||||||
|
alt="QR code for {{ facility.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 facility'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 at the facility entrance or in each restroom.
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -17,6 +17,12 @@
|
|||||||
<i class="bi bi-graph-up-arrow"></i> Scorecard
|
<i class="bi bi-graph-up-arrow"></i> Scorecard
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
|
||||||
|
<a href="{{ url_for('facilities.facility_qr_page', facility_id=facility.id) }}"
|
||||||
|
class="btn btn-outline-dark" title="Printable QR code for this facility">
|
||||||
|
<i class="bi bi-qr-code"></i> QR Code
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
{% if current_user.role in ['admin', 'director'] %}
|
{% if current_user.role in ['admin', 'director'] %}
|
||||||
<a href="{{ url_for('facilities.edit_facility', facility_id=facility.id) }}" class="btn btn-outline-primary">
|
<a href="{{ url_for('facilities.edit_facility', facility_id=facility.id) }}" class="btn btn-outline-primary">
|
||||||
<i class="bi bi-pencil"></i> Edit
|
<i class="bi bi-pencil"></i> Edit
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<!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>{{ facility.name }} — Facility Status</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:#f6f8fa; }
|
||||||
|
.wrap { max-width: 640px; margin: 0 auto; padding: 16px; }
|
||||||
|
.hpot { position:absolute !important; left:-9999px !important; width:1px; height:1px; overflow:hidden; }
|
||||||
|
.rating-num { font-size: 2.4rem; font-weight: 700; line-height: 1; }
|
||||||
|
.stat { background:#fff; border-radius:.75rem; }
|
||||||
|
</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="text-center mt-2 mb-3">
|
||||||
|
<div class="text-muted small text-uppercase" style="letter-spacing:.08em;">Facility Status</div>
|
||||||
|
<h1 class="h3 fw-bold mb-0">{{ facility.name }}</h1>
|
||||||
|
{% if facility.address %}
|
||||||
|
<div class="text-muted small mt-1"><i class="bi bi-geo-alt"></i> {{ facility.address }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Quality rating ── #}
|
||||||
|
<div class="stat shadow-sm p-4 mb-3 text-center">
|
||||||
|
<div class="text-muted small text-uppercase mb-2">Cleaning Quality</div>
|
||||||
|
<span class="badge bg-{{ rating_colour }} fs-6 mb-2">{{ rating_label }}</span>
|
||||||
|
<div class="rating-num text-{{ rating_colour }}">
|
||||||
|
{{ ('%.0f' % avg_score) ~ '%' if avg_score is not none else '—' }}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small mt-1">Average score, last 90 days</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Facts ── #}
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="stat shadow-sm p-3 h-100">
|
||||||
|
<div class="text-muted small"><i class="bi bi-calendar-check"></i> Last Inspected</div>
|
||||||
|
<div class="fw-semibold">
|
||||||
|
{{ last_inspected.strftime('%b %d, %Y') if last_inspected else 'Not yet' }}
|
||||||
|
</div>
|
||||||
|
{% if last_score is not none %}
|
||||||
|
<div class="text-muted small">Scored {{ '%.0f' % last_score }}%</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="stat shadow-sm p-3 h-100">
|
||||||
|
<div class="text-muted small"><i class="bi bi-exclamation-triangle"></i> Open Issues</div>
|
||||||
|
<div class="fw-semibold fs-4">{{ open_issue_count }}</div>
|
||||||
|
<div class="text-muted small">Currently being tracked</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Report a problem ── #}
|
||||||
|
<div class="stat shadow-sm p-4 mb-4">
|
||||||
|
<h2 class="h5 fw-bold"><i class="bi bi-megaphone"></i> Report a Problem</h2>
|
||||||
|
<p class="text-muted small">
|
||||||
|
Notice something that needs attention? Let the cleaning team know.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="POST"
|
||||||
|
action="{{ url_for('public.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. 2nd floor men's restroom") }}
|
||||||
|
</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>
|
||||||
@@ -275,3 +275,25 @@ class SetPasswordForm(FlaskForm):
|
|||||||
existing = User.query.filter_by(username=field.data.strip()).first()
|
existing = User.query.filter_by(username=field.data.strip()).first()
|
||||||
if existing:
|
if existing:
|
||||||
raise ValidationError('This username is already taken. Please choose another.')
|
raise ValidationError('This username is already taken. Please choose another.')
|
||||||
|
|
||||||
|
# ── Public facility QR — occupant "Report a problem" ─────────────────────────
|
||||||
|
|
||||||
|
class PublicIssueReportForm(FlaskForm):
|
||||||
|
"""Login-free issue report submitted from a facility's public QR page.
|
||||||
|
|
||||||
|
`website` is a honeypot: real users never see it (hidden via CSS); bots
|
||||||
|
that fill every field trip it and the submission is silently rejected.
|
||||||
|
"""
|
||||||
|
area_label = StringField('Where in the building?',
|
||||||
|
validators=[Optional(), Length(max=120)])
|
||||||
|
description = TextAreaField('Describe the problem',
|
||||||
|
validators=[DataRequired(), Length(min=5, max=2000)])
|
||||||
|
reporter_name = StringField('Your name (optional)',
|
||||||
|
validators=[Optional(), Length(max=100)])
|
||||||
|
reporter_contact = StringField('Email or phone (optional)',
|
||||||
|
validators=[Optional(), Length(max=120)])
|
||||||
|
photo = FileField('Add a photo (optional)',
|
||||||
|
validators=[Optional(),
|
||||||
|
FileAllowed(['jpg', 'jpeg', 'png', 'gif'],
|
||||||
|
'Images only (jpg, png, gif).')])
|
||||||
|
website = StringField('Website') # honeypot — must stay empty
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""phase34 — facilities.public_token for public QR landing pages
|
||||||
|
|
||||||
|
Adds a unique, unguessable token per facility. The customer-facing QR code
|
||||||
|
encodes /f/<public_token>, which serves an occupant-friendly summary + a
|
||||||
|
"report a problem" form with no login required. Existing facilities are
|
||||||
|
backfilled with a generated token.
|
||||||
|
|
||||||
|
Uses INFORMATION_SCHEMA column-existence check — safe to re-run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
revision = 'phase34_facility_qr'
|
||||||
|
down_revision = 'phase33_contract_recipients'
|
||||||
|
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, 'facilities', 'public_token'):
|
||||||
|
op.execute(sa.text(
|
||||||
|
"ALTER TABLE facilities ADD COLUMN public_token VARCHAR(48) NULL"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Backfill a unique token for every existing facility that lacks one.
|
||||||
|
rows = bind.execute(sa.text(
|
||||||
|
"SELECT id FROM facilities WHERE public_token IS NULL OR public_token = ''"
|
||||||
|
)).fetchall()
|
||||||
|
for (fid,) in rows:
|
||||||
|
# token_urlsafe(24) → ~32 URL-safe chars; well within VARCHAR(48).
|
||||||
|
token = secrets.token_urlsafe(24)
|
||||||
|
bind.execute(
|
||||||
|
sa.text("UPDATE facilities SET public_token = :tok WHERE id = :id"),
|
||||||
|
{"tok": token, "id": fid},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enforce uniqueness now that every row has a value.
|
||||||
|
# (Separate from the ADD COLUMN so the backfill can complete first.)
|
||||||
|
existing_idx = bind.execute(sa.text(
|
||||||
|
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS "
|
||||||
|
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'facilities' "
|
||||||
|
"AND INDEX_NAME = 'uq_facility_public_token'"
|
||||||
|
)).scalar()
|
||||||
|
if not existing_idx:
|
||||||
|
op.execute(sa.text(
|
||||||
|
"CREATE UNIQUE INDEX uq_facility_public_token "
|
||||||
|
"ON facilities (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 = 'facilities' "
|
||||||
|
"AND INDEX_NAME = 'uq_facility_public_token'"
|
||||||
|
)).scalar()
|
||||||
|
if existing_idx:
|
||||||
|
op.execute(sa.text("DROP INDEX uq_facility_public_token ON facilities"))
|
||||||
|
if _column_exists(bind, 'facilities', 'public_token'):
|
||||||
|
op.execute(sa.text("ALTER TABLE facilities DROP COLUMN public_token"))
|
||||||
@@ -22,3 +22,4 @@ pytz
|
|||||||
pyJWT
|
pyJWT
|
||||||
openpyxl
|
openpyxl
|
||||||
groq
|
groq
|
||||||
|
qrcode
|
||||||
|
|||||||
Reference in New Issue
Block a user