Jul 9 - Update to allow customer to manage their facilities' QR codes

This commit is contained in:
2026-07-09 09:58:43 -04:00
parent 97a559d5c0
commit b2ac816bc2
6 changed files with 58 additions and 28 deletions
+3 -1
View File
@@ -407,6 +407,8 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi
| Notification Matrix | ✅ only | ❌ | ❌ | ❌ | ❌ | | Notification Matrix | ✅ only | ❌ | ❌ | ❌ | ❌ |
| Customers | ✅ | ✅ | ❌ | ❌ | ❌ | | Customers | ✅ | ✅ | ❌ | ❌ | ❌ |
| Facilities | ✅ | ✅ | ✅ | read | scoped | | Facilities | ✅ | ✅ | ✅ | read | scoped |
| Facility QR (view/print) | ✅ | ✅ | ✅ | ✅ | scoped |
| Facility QR (regenerate) | ✅ | ✅ | ❌ | ❌ | scoped |
| Contracts | ✅ | ✅ | ✅ | read | scoped | | Contracts | ✅ | ✅ | ✅ | read | scoped |
| Templates | ✅ | ✅ | ❌ | ❌ | ❌ | | Templates | ✅ | ✅ | ❌ | ❌ | ❌ |
| Inspections (execute) | ✅ | ✅ | ✅ | ✅ | read | | Inspections (execute) | ✅ | ✅ | ✅ | ✅ | read |
@@ -438,7 +440,7 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi
|---|---|---| |---|---|---|
| `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 + QR code: `/<id>/qr` printable page, `/<id>/qr.png` image, `POST /<id>/qr/regenerate` (admin/director — invalidates old printed code), `/qr/print-all[?contract_id=]` bulk sheet. All QR routes are staff-only (customers 403); inspectors scoped to contracted facilities. | | `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. | | `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 |
+36 -22
View File
@@ -97,7 +97,9 @@ def view_facility(facility_id):
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) ──────────────────────────────────── # ── Public QR code ────────────────────────────────────────────────────────────
# Staff (admin/director/pm/inspector) may access QR for any facility; customers
# may access QR only for facilities in their assigned scope.
def _public_facility_url(facility): def _public_facility_url(facility):
"""Absolute URL the QR encodes — the login-free occupant summary page.""" """Absolute URL the QR encodes — the login-free occupant summary page."""
@@ -108,15 +110,27 @@ def _public_facility_url(facility):
token=facility.public_token, _external=True) token=facility.public_token, _external=True)
@bp.route('/<int:facility_id>/qr.png') def _facility_for_qr_or_403(facility_id):
@login_required """Load a facility for a QR action, enforcing customer facility scope.
def facility_qr_png(facility_id):
"""Return the facility's QR code as a PNG image (staff only).""" Customers may only touch QR codes for facilities they are assigned to; all
if current_user.role == 'customer': other (staff) roles have unrestricted QR access.
abort(403) """
facility = db.session.get(Facility, facility_id) facility = db.session.get(Facility, facility_id)
if facility is None: if facility is None:
abort(404) abort(404)
if current_user.role == 'customer':
cids = get_customer_scope(current_user) or []
if facility.id not in cids:
abort(403)
return facility
@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."""
facility = _facility_for_qr_or_403(facility_id)
# Token may not exist for pre-phase34 rows viewed before any commit. # Token may not exist for pre-phase34 rows viewed before any commit.
created = not facility.public_token created = not facility.public_token
@@ -141,11 +155,7 @@ def facility_qr_png(facility_id):
@login_required @login_required
def facility_qr_page(facility_id): def facility_qr_page(facility_id):
"""Printable page: facility name + QR + public URL + posting instructions.""" """Printable page: facility name + QR + public URL + posting instructions."""
if current_user.role == 'customer': facility = _facility_for_qr_or_403(facility_id)
abort(403)
facility = db.session.get(Facility, facility_id)
if facility is None:
abort(404)
public_url = _public_facility_url(facility) public_url = _public_facility_url(facility)
db.session.commit() # persist token if it was just generated db.session.commit() # persist token if it was just generated
return render_template('facilities/qr.html', return render_template('facilities/qr.html',
@@ -154,12 +164,15 @@ def facility_qr_page(facility_id):
@bp.route('/<int:facility_id>/qr/regenerate', methods=['POST']) @bp.route('/<int:facility_id>/qr/regenerate', methods=['POST'])
@login_required @login_required
@supervisor_required
def facility_qr_regenerate(facility_id): def facility_qr_regenerate(facility_id):
"""Mint a NEW public token, invalidating any previously printed QR code.""" """Mint a NEW public token, invalidating any previously printed QR code.
facility = db.session.get(Facility, facility_id)
if facility is None: Allowed for admin/director, and for customers on their own assigned
abort(404) facilities. Project managers and inspectors cannot regenerate.
"""
facility = _facility_for_qr_or_403(facility_id)
if current_user.role not in ('admin', 'director', 'customer'):
abort(403)
facility.public_token = Facility.generate_public_token() facility.public_token = Facility.generate_public_token()
db.session.commit() db.session.commit()
@@ -178,17 +191,18 @@ def facility_qr_regenerate(facility_id):
def facility_qr_print_all(): def facility_qr_print_all():
"""Printable sheet of QR codes for all facilities the user can see. """Printable sheet of QR codes for all facilities the user can see.
Optional ?contract_id=<id> limits the sheet to one contract. Customers have Optional ?contract_id=<id> limits the sheet to one contract. Inspectors are
no QR access (403); inspectors are scoped to their contracted facilities. scoped to their contracted facilities; customers to their assigned
facilities; managers see all active facilities.
""" """
if current_user.role == 'customer':
abort(403)
contract_id = request.args.get('contract_id', type=int) contract_id = request.args.get('contract_id', type=int)
if current_user.role == 'inspector': if current_user.role == 'inspector':
fids = get_inspector_scope(current_user) or [] fids = get_inspector_scope(current_user) or []
query = Facility.query.filter(Facility.id.in_(fids), Facility.active == True) query = Facility.query.filter(Facility.id.in_(fids), Facility.active == True)
elif current_user.role == 'customer':
fids = get_customer_scope(current_user) or []
query = Facility.query.filter(Facility.id.in_(fids), Facility.active == True)
else: else:
query = Facility.query.filter(Facility.active == True) query = Facility.query.filter(Facility.active == True)
+1 -3
View File
@@ -8,12 +8,10 @@
<h2><i class="bi bi-building"></i> Facilities</h2> <h2><i class="bi bi-building"></i> Facilities</h2>
</div> </div>
<div class="col-md-6 text-end"> <div class="col-md-6 text-end">
{% if current_user.role != 'customer' %}
<a href="{{ url_for('facilities.facility_qr_print_all') }}" <a href="{{ url_for('facilities.facility_qr_print_all') }}"
class="btn btn-outline-dark" title="Printable sheet of every facility's QR code"> class="btn btn-outline-dark" title="Printable sheet of your facilities' QR codes">
<i class="bi bi-qr-code"></i> Print All QR Codes <i class="bi bi-qr-code"></i> Print All QR Codes
</a> </a>
{% endif %}
{% if current_user.role in ['admin', 'director'] %} {% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('facilities.create_facility') }}" class="btn btn-primary"> <a href="{{ url_for('facilities.create_facility') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add Facility <i class="bi bi-plus-circle"></i> Add Facility
+1 -1
View File
@@ -17,7 +17,7 @@
<i class="bi bi-arrow-left"></i> Back to Facility <i class="bi bi-arrow-left"></i> Back to Facility
</a> </a>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
{% if current_user.role in ['admin', 'director'] %} {% if current_user.role in ['admin', 'director', 'customer'] %}
<form method="POST" <form method="POST"
action="{{ url_for('facilities.facility_qr_regenerate', facility_id=facility.id) }}" action="{{ url_for('facilities.facility_qr_regenerate', facility_id=facility.id) }}"
onsubmit="return confirm('Regenerate this QR code? Any codes already printed and posted for {{ facility.name }} will STOP working and must be reprinted.');"> onsubmit="return confirm('Regenerate this QR code? Any codes already printed and posted for {{ facility.name }} will STOP working and must be reprinted.');">
+1 -1
View File
@@ -17,7 +17,7 @@
<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'] %} {% if current_user.role in ['admin', 'director', 'project_manager', 'customer'] %}
<a href="{{ url_for('facilities.facility_qr_page', facility_id=facility.id) }}" <a href="{{ url_for('facilities.facility_qr_page', facility_id=facility.id) }}"
class="btn btn-outline-dark" title="Printable QR code for this facility"> class="btn btn-outline-dark" title="Printable QR code for this facility">
<i class="bi bi-qr-code"></i> QR Code <i class="bi bi-qr-code"></i> QR Code
+16
View File
@@ -29,6 +29,22 @@ When you scan the code (or open the link), you'll see:
> For privacy, the public page shows only summary information. It never displays > For privacy, the public page shows only summary information. It never displays
> individual issue details, inspector names, or any other facility's data. > individual issue details, inspector names, or any other facility's data.
### Getting, printing, and regenerating the QR code (from your portal)
You can produce the QR code yourself for any facility you're assigned to:
- **One facility** — open the facility from your dashboard/facilities list and
click **QR Code**. This opens a printable page with the code and its link;
click **Print** to print it.
- **All your facilities at once** — on the **Facilities** page click **Print All
QR Codes** for a single sheet covering every facility you're assigned to.
- **Regenerate** — on a facility's QR page, click **Regenerate** to issue a new
code. Use this if a posted code is defaced or you want to retire an old one.
**Any previously printed code for that facility stops working**, so reprint and
repost after regenerating.
*[Screenshot: facility QR page with Print and Regenerate buttons]*
### Reporting a problem ### Reporting a problem
1. On the facility status page, scroll to **Report a Problem**. 1. On the facility status page, scroll to **Report a Problem**.