From 512a80837a49764343f442ad19e491b751f31d85 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Fri, 10 Jul 2026 14:05:54 -0400 Subject: [PATCH] Jul 10 - Update facility's area with QR code --- CLAUDE.md | 24 ++- app/models/facility.py | 17 ++ app/routes/facilities.py | 88 +++++++- app/routes/public.py | 190 ++++++++++++++++++ app/templates/facilities/area_qr.html | 64 ++++++ app/templates/facilities/view.html | 6 + app/templates/public/area.html | 181 +++++++++++++++++ .../versions/phase39_area_public_token.py | 71 +++++++ 8 files changed, 635 insertions(+), 6 deletions(-) create mode 100644 app/templates/facilities/area_qr.html create mode 100644 app/templates/public/area.html create mode 100644 migrations/versions/phase39_area_public_token.py diff --git a/CLAUDE.md b/CLAUDE.md index 768b783..edb616c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/` — 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/` — 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/` and `/f/` 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: `//qr` printable page, `//qr.png` image, `POST //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 /` occupant facility summary; `POST //report` occupant issue report (rate-limited `5/hour`, honeypot). Resolves ACTIVE facility by `public_token` or 404. | +| `facilities` | `/facilities` | CRUD + area management + QR code: `//qr` printable page, `//qr.png` image, `POST //qr/regenerate` (invalidates old printed code), `/qr/print-all[?contract_id=]` bulk sheet. **Per-area QR (Phase 39):** `/areas//qr`, `/areas//qr.png`, `POST /areas//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 /` occupant facility summary + `POST //report` occupant issue report; `GET /area/` per-area summary + `POST /area//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 (`//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) | @@ -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 diff --git a/app/models/facility.py b/app/models/facility.py index b3e3fce..09f5bf7 100644 --- a/app/models/facility.py +++ b/app/models/facility.py @@ -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/, 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'' \ No newline at end of file diff --git a/app/routes/facilities.py b/app/routes/facilities.py index 4e5f0a5..b6463c8 100644 --- a/app/routes/facilities.py +++ b/app/routes/facilities.py @@ -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//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//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//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('//edit', methods=['GET', 'POST']) @login_required @supervisor_required @@ -297,7 +382,8 @@ 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() logger.info('FACILITIES | create_area | user=%s | area_id=%s name=%r facility=%r', diff --git a/app/routes/public.py b/app/routes/public.py index bb91f8e..900932c 100644 --- a/app/routes/public.py +++ b/app/routes/public.py @@ -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('/', 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/', 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('//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//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)) diff --git a/app/templates/facilities/area_qr.html b/app/templates/facilities/area_qr.html new file mode 100644 index 0000000..8424d1b --- /dev/null +++ b/app/templates/facilities/area_qr.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% block title %}QR Code — {{ area.name }}{% endblock %} + +{% block content %} + + +
+ + Back to Facility + +
+ {% if current_user.role in ['admin', 'director', 'customer'] %} +
+ + +
+ {% endif %} + +
+
+ +
+
+
+ Scan for Area Status +
+

{{ area.name }}

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

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

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

+ Tip: print this and post it inside the area itself (e.g. on the restroom door). +

+{% endblock %} diff --git a/app/templates/facilities/view.html b/app/templates/facilities/view.html index a006b5d..9384628 100644 --- a/app/templates/facilities/view.html +++ b/app/templates/facilities/view.html @@ -135,6 +135,12 @@ {{ area.inspections.count() }} + {% if current_user.role in ['admin', 'director', 'project_manager', 'customer'] %} + + + + {% endif %} {% if current_user.role in ['admin', 'director'] %} diff --git a/app/templates/public/area.html b/app/templates/public/area.html new file mode 100644 index 0000000..de5a675 --- /dev/null +++ b/app/templates/public/area.html @@ -0,0 +1,181 @@ + + + + + + + {{ area.name }} — {{ facility.name }} + + + + + +
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endwith %} + + {# ── Header ── #} +
+
+
+

{{ area.name }}

+
+ {{ facility.name }} + {% if area.area_type %} · {{ area.area_type|title }}{% endif %} + {% if facility.address %}
{{ facility.address }}{% endif %} +
+
+
+ + {# ── KPI row ── #} +
+
+
+
+ {{ ('%.1f' % avg_score) ~ '%' if avg_score is not none else '—' }} +
+
Avg Score
+
90 Days
+
+
+
+
+
{{ inspections_90 }}
+
Inspections
+
90 Days
+
+
+
+
+
{{ open_issue_count }}
+
Open
+
Issues
+
+
+
+
+
{{ resolved_90 }}
+
Resolved
+
90 Days
+
+
+
+ + {# ── Score trend (aggregate only) ── #} +
+
Score Trend — 30 Days vs Prior 30
+
+ {% if trend_delta is none %} + Not enough data yet + {% elif trend_delta > 0 %} + +{{ '%.1f' % trend_delta }} pts + {% elif trend_delta < 0 %} + {{ '%.1f' % trend_delta }} pts + {% else %} + No change + {% endif %} +
+
+ + {# ── Cleaning quality rating ── #} +
+
Cleaning Quality
+ {{ rating_label }} +
+ + {# ── Recent inspections (DATES ONLY — occupant-safe) ── #} + {% if recent_dates %} +
+
Recent Inspections
+ {% for d in recent_dates %} +
+ {{ d.strftime('%b %d, %Y') }} + Completed +
+ {% endfor %} +
This area is inspected regularly by our quality team.
+
+ {% endif %} + + {# ── Report a problem ── #} +
+

Report a Problem

+

+ Notice something in {{ area.name }} 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. third stall from the door") }} +
+ +
+ {{ 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/migrations/versions/phase39_area_public_token.py b/migrations/versions/phase39_area_public_token.py new file mode 100644 index 0000000..85d05e2 --- /dev/null +++ b/migrations/versions/phase39_area_public_token.py @@ -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/, 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"))