diff --git a/CLAUDE.md b/CLAUDE.md index c87c578..46938ef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -197,10 +197,13 @@ users: id, username (unique, indexed), full_name, email (unique, indexed), ### 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 ``` +**`public_token`** (Phase 34): unguessable per-facility token encoded in the facility's QR code. The QR points at `/f/` — 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` ### Project / CustomerAssignment @@ -397,7 +400,8 @@ contract_notification_recipients: |---|---|---| | `auth` | `/auth` | `/login`, `/logout`, `/profile`, `/users/*`, `/notification-matrix` | | `dashboard` | `/` | `GET /`, `/facility-trend` (AJAX) | -| `facilities` | `/facilities` | CRUD + area management | +| `facilities` | `/facilities` | CRUD + area management + QR code (`//qr` printable page, `//qr.png` image — staff only, customers 403) | +| `public` | `/f` | **No login.** `GET /` occupant facility summary; `POST //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 (`//notify-recipients/add`, `/notify-recipients//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) | @@ -709,7 +713,8 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif → phase30_device_registry → phase31_device_registry → phase32_device_token_columns - → phase33_contract_recipients ← HEAD + → phase33_contract_recipients + → phase34_facility_qr ← HEAD ``` ### phase21_performance_indexes @@ -794,6 +799,17 @@ flask db upgrade 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:** ```bash 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. | | 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. | +| 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. | --- diff --git a/app/__init__.py b/app/__init__.py index b14a753..4553198 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -176,6 +176,7 @@ def create_app(config_name='default'): from app.routes import support # Support chat + admin tickets from app.routes import broadcast # Admin broadcast notifications 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(dashboard.bp) @@ -192,6 +193,7 @@ def create_app(config_name='default'): app.register_blueprint(support.bp) app.register_blueprint(broadcast.bp) app.register_blueprint(devices.bp) + app.register_blueprint(public.bp) # ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ─────────────────── # The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed. diff --git a/app/models/facility.py b/app/models/facility.py index 6634224..b3e3fce 100644 --- a/app/models/facility.py +++ b/app/models/facility.py @@ -1,3 +1,4 @@ +import secrets from app import db 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'), nullable=True, index=True) + # Phase 34: unguessable token encoded in the facility's public QR code. + # The QR points at /f/, 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 areas = db.relationship('Area', 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): return f'' diff --git a/app/routes/facilities.py b/app/routes/facilities.py index b34c271..37eec67 100644 --- a/app/routes/facilities.py +++ b/app/routes/facilities.py @@ -70,6 +70,7 @@ def create_facility(): active=form.active.data ) + facility.ensure_public_token() # QR landing-page token db.session.add(facility) db.session.commit() 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() 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('//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('//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('//edit', methods=['GET', 'POST']) @login_required @supervisor_required diff --git a/app/routes/public.py b/app/routes/public.py new file mode 100644 index 0000000..bbf5aaa --- /dev/null +++ b/app/routes/public.py @@ -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/ (an unguessable token, so the pages +cannot be enumerated by facility id). + +Routes +------ +GET /f/ Occupant-friendly facility summary (no login). +POST /f//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('/', 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('//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)) diff --git a/app/templates/facilities/qr.html b/app/templates/facilities/qr.html new file mode 100644 index 0000000..676aeb4 --- /dev/null +++ b/app/templates/facilities/qr.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}QR Code — {{ facility.name }}{% endblock %} + +{% block content %} + + +
+ + Back to Facility + + +
+ +
+
+
+ Scan for Facility Status +
+

{{ facility.name }}

+ + QR code for {{ facility.name }} + +

Scan this code with your phone camera to see this facility's + recent cleaning quality and to report a problem.

+ + {% if public_url %} +
+ Or visit:
+ {{ public_url }} +
+ {% endif %} +
+
+ +

+ Tip: print this and post it at the facility entrance or in each restroom. +

+{% endblock %} diff --git a/app/templates/facilities/view.html b/app/templates/facilities/view.html index 8bf212e..5f097e2 100644 --- a/app/templates/facilities/view.html +++ b/app/templates/facilities/view.html @@ -17,6 +17,12 @@ Scorecard {% endif %} + {% if current_user.role in ['admin', 'director', 'project_manager'] %} + + QR Code + + {% endif %} {% if current_user.role in ['admin', 'director'] %} Edit diff --git a/app/templates/public/facility.html b/app/templates/public/facility.html new file mode 100644 index 0000000..e912a1f --- /dev/null +++ b/app/templates/public/facility.html @@ -0,0 +1,131 @@ + + + + + + + {{ facility.name }} — Facility Status + + + + + +
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endwith %} + + {# ── Header ── #} +
+
Facility Status
+

{{ facility.name }}

+ {% if facility.address %} +
{{ facility.address }}
+ {% endif %} +
+ + {# ── Quality rating ── #} +
+
Cleaning Quality
+ {{ rating_label }} +
+ {{ ('%.0f' % avg_score) ~ '%' if avg_score is not none else '—' }} +
+
Average score, last 90 days
+
+ + {# ── Facts ── #} +
+
+
+
Last Inspected
+
+ {{ last_inspected.strftime('%b %d, %Y') if last_inspected else 'Not yet' }} +
+ {% if last_score is not none %} +
Scored {{ '%.0f' % last_score }}%
+ {% endif %} +
+
+
+
+
Open Issues
+
{{ open_issue_count }}
+
Currently being tracked
+
+
+
+ + {# ── Report a problem ── #} +
+

Report a Problem

+

+ Notice something that needs attention? Let the cleaning team know. +

+ +
+ {{ form.hidden_tag() }} + + {# Honeypot — hidden from humans; bots that fill it are rejected #} + + +
+ {{ form.area_label.label(class="form-label small fw-semibold") }} + {{ form.area_label(class="form-control", placeholder="e.g. 2nd floor men's restroom") }} +
+ +
+ {{ 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 %} +
{{ e }}
+ {% endfor %} +
+ +
+
+ {{ form.reporter_name.label(class="form-label small fw-semibold") }} + {{ form.reporter_name(class="form-control") }} +
+
+ {{ form.reporter_contact.label(class="form-label small fw-semibold") }} + {{ form.reporter_contact(class="form-control") }} +
+
+ +
+ {{ form.photo.label(class="form-label small fw-semibold") }} + {{ form.photo(class="form-control", accept="image/*") }} + {% for e in form.photo.errors %} +
{{ e }}
+ {% endfor %} +
+ + +
+
+ +
+ Janitorial Quality Control +
+
+ + diff --git a/app/utils/forms.py b/app/utils/forms.py index 2c51328..eb5fb2f 100644 --- a/app/utils/forms.py +++ b/app/utils/forms.py @@ -274,4 +274,26 @@ class SetPasswordForm(FlaskForm): def validate_username(self, field): existing = User.query.filter_by(username=field.data.strip()).first() if existing: - raise ValidationError('This username is already taken. Please choose another.') \ No newline at end of file + 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 diff --git a/migrations/versions/phase34_facility_public_token.py b/migrations/versions/phase34_facility_public_token.py new file mode 100644 index 0000000..9623e86 --- /dev/null +++ b/migrations/versions/phase34_facility_public_token.py @@ -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/, 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")) diff --git a/requirements.txt b/requirements.txt index 1a06143..3d0918e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,3 +22,4 @@ pytz pyJWT openpyxl groq +qrcode