Jul 16 - Update facilities QR codes - report can upload up to 5 photos

This commit is contained in:
2026-07-16 10:32:47 -04:00
parent faf4796b4a
commit 5a42b21a04
5 changed files with 90 additions and 22 deletions
+3 -3
View File
@@ -279,7 +279,7 @@ Triage of `handler_type` + facility/vendor detail fields on the **update** panel
| Column | Type | Populated by | Displayed as |
|---|---|---|---|
| `photo_path` | `VARCHAR(255)` | Web form upload OR first iPad photo | "Photo Evidence" (primary) |
| `mobile_photo_paths` | `JSON` (`list[str]`) | iPad PATCH `/issues/<id>/photos` — extra evidence photos | "Photo Evidence" (additional) |
| `mobile_photo_paths` | `JSON` (`list[str]`) | iPad PATCH `/issues/<id>/photos` — extra evidence photos; **also public QR "report a problem" (photos 25)** | "Photo Evidence" (additional) |
| `result_photos` | `JSON` (`list[str]`) | Web update form file upload — resolution photos | "Resolution Details" |
**Rule:** Never write iPad evidence photos into `result_photos`. They belong in `mobile_photo_paths` so they appear under "Photo Evidence" on the web, not "Resolution Details".
@@ -472,7 +472,7 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi
| `auth` | `/auth` | `/login`, `/logout`, `/profile`, `/users/*`, `/notification-matrix` |
| `dashboard` | `/` | `GET /`, `/facility-trend` (AJAX) |
| `facilities` | `/facilities` | CRUD + area management + QR code: `/<id>/qr` printable page, `/<id>/qr.png` image, `POST /<id>/qr/regenerate` (invalidates old printed code), `/qr/print-all[?contract_id=]` bulk sheet. **Per-area QR (Phase 39):** `/areas/<id>/qr`, `/areas/<id>/qr.png`, `POST /areas/<id>/qr/regenerate` — mirror the facility QR routes; scope enforced by `_area_for_qr_or_403()` via the area's parent facility. **Customers may use all QR actions (including regenerate) for their own assigned facilities**; inspectors/PM/admin/director for any. Scope enforced by `_facility_for_qr_or_403()` (customers) / `get_customer_scope` (print-all). Regenerate is limited to admin/director + scoped customer (PM/inspector excluded). **QR print/export page:** `GET /qr/print-all` is a selectable sheet with filters `?contract_id=` / `?facility_id=` / `?include_areas=1` (contract narrows the facility dropdown; areas render each facility's per-area QR cards). Each card is a `<label>` wrapping a checkbox; **Print Selected** (JS toggles `body.print-selected-only` so `@media print` hides unticked cards) and **Export Selected to PDF** (`POST /qr/export-pdf`, repeated `facility_ids`/`area_ids`, scope re-checked per id via the `_*_for_qr_or_403()` helpers, streams `generate_qr_codes_pdf()` output; logs `ACTION_EXPORT`). Inspectors 403. QR PNG bytes for the PDF come from `_qr_png_bytes(url)`. |
| `public` | `/f` | **No login.** `GET /<token>` occupant facility summary + `POST /<token>/report` occupant issue report; `GET /area/<token>` per-area summary + `POST /area/<token>/report` (Phase 39, files with `area_id` set). All report POSTs rate-limited `5/hour`, honeypot-guarded. Resolves ACTIVE facility (area's parent must be active) by `public_token` or 404. |
| `public` | `/f` | **No login.** `GET /<token>` occupant facility summary + `POST /<token>/report` occupant issue report; `GET /area/<token>` per-area summary + `POST /area/<token>/report` (Phase 39, files with `area_id` set). Report form accepts **up to 5 photos** (`_save_report_photos()``photo_path` + `mobile_photo_paths`). All report POSTs rate-limited `5/hour`, honeypot-guarded. Resolves ACTIVE facility (area's parent must be active) by `public_token` or 404. |
| `projects` | `/projects` | CRUD + customer assignment management + notification-recipient add/remove (`/<id>/notify-recipients/add`, `/notify-recipients/<rid>/remove` — admin only) |
| `customers` | `/customers` | list, invite, set-password, manage, import CSV |
| `inspections` | `/inspections` | list, start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect, upload-photo (AJAX) |
@@ -1312,7 +1312,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. |
| 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=...)`. **Photos:** the report form accepts **up to 5 photos** (`PublicIssueReportForm.photos`, a `MultipleFileField`); `_save_report_photos()` in `public.py` saves them via the shared magic-byte-validated `_save_photo()` (cap `MAX_REPORT_PHOTOS=5`) and stores the first in `Issue.photo_path`, the rest in `Issue.mobile_photo_paths` — never `result_photos` (rule 44), so they all render under "Photo Evidence". 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. |
| 75 | **Email is stored lowercased; look it up case-insensitively** | User/customer email is normalized to `.strip().lower()` at every write site (`auth.py` profile/create/edit, `customers.py` invite/edit). Forgot-password lookup uses `db.func.lower(User.email) == input` so a mixed-case legacy row still matches — a plain `filter_by(email=...)` silently missed them and sent no reset (the failure was invisible because of the generic "if an account exists…" message). Keep both halves: normalize on write, case-insensitive on lookup. |
| 76 | **Transactional email `From` must be an SMTP-authorized identity, per-domain branding via display name only** | Reset-password sends from `MAIL_DEFAULT_SENDER`; customer invite sends from `branded_sender()` = `(per-domain display name, authorized address)`. A per-host `noreply@<subdomain>` sender is accepted by the relay then dropped by SPF/DMARC. See rule 64 and §8 `mail_utils.py`. |
| 77 | **`GET /api/v1/scheduled-inspections` is inspector-scoped by `inspector_id`, admin/director/PM see all** | New `app/api/scheduled.py` blueprint. Register in `app/api/__init__.py` AND `csrf.exempt(_api_scheduled_bp)` in `app/__init__.py` — the child-blueprint CSRF exemption never cascades from the parent. Read-only; do not add write/fulfil endpoints here (the schedule lifecycle stays in `routes/scheduled_inspections.py`). |
+37 -9
View File
@@ -38,6 +38,30 @@ logger = logging.getLogger(__name__)
bp = Blueprint('public', __name__, url_prefix='/f')
#: Maximum number of photos an occupant may attach to a public report.
MAX_REPORT_PHOTOS = 5
def _save_report_photos(file_list):
"""Save up to MAX_REPORT_PHOTOS uploaded photos from a public report.
Returns (photo_path, extra_paths) where photo_path is the primary evidence
photo (or None) and extra_paths is a list of the remaining paths (or None).
Splitting this way mirrors the Issue photo model: the first photo lives in
`photo_path`, the rest in `mobile_photo_paths` so they all render together
under "Photo Evidence" on the web (rule 44 never `result_photos`).
"""
from app.routes.inspections import _save_photo
saved = []
for f in (file_list or [])[:MAX_REPORT_PHOTOS]:
path = _save_photo(f, subfolder='issue_photos')
if path:
saved.append(path)
photo_path = saved[0] if saved else None
extra_paths = saved[1:] if len(saved) > 1 else None
return photo_path, extra_paths
def _facility_by_token_or_404(token: str) -> Facility:
"""Resolve an ACTIVE facility from its public token, else 404."""
if not token:
@@ -320,9 +344,9 @@ def report_problem(token):
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')
# Save up to 5 optional photos through the shared, magic-byte-validated
# saver. First → photo_path, the rest → mobile_photo_paths.
photo_path, extra_photos = _save_report_photos(form.photos.data)
# Fold optional reporter identity + location into the description; the
# public reporter is not a User, so reported_by stays NULL.
@@ -342,6 +366,7 @@ def report_problem(token):
severity = 'medium',
description = description,
photo_path = photo_path,
mobile_photo_paths = extra_photos,
status = 'open',
reported_at = now_eastern(),
reported_by = None,
@@ -349,8 +374,9 @@ def report_problem(token):
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))
_photo_count = (1 if photo_path else 0) + (len(extra_photos) if extra_photos else 0)
logger.info('PUBLIC REPORT | issue_id=%s | facility_id=%s | ip=%s | photos=%s',
issue.id, facility.id, request.remote_addr, _photo_count)
# Reuse the standard issue-created routing (staff + facility customers).
notify_by_matrix(
@@ -390,8 +416,8 @@ def area_report_problem(token):
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')
# Save up to 5 optional photos (first → photo_path, rest → mobile_photo_paths).
photo_path, extra_photos = _save_report_photos(form.photos.data)
# 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.
@@ -411,6 +437,7 @@ def area_report_problem(token):
severity = 'medium',
description = description,
photo_path = photo_path,
mobile_photo_paths = extra_photos,
status = 'open',
reported_at = now_eastern(),
reported_by = None,
@@ -418,8 +445,9 @@ def area_report_problem(token):
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))
_photo_count = (1 if photo_path else 0) + (len(extra_photos) if extra_photos else 0)
logger.info('PUBLIC REPORT | issue_id=%s | area_id=%s | facility_id=%s | ip=%s | photos=%s',
issue.id, area.id, facility.id, request.remote_addr, _photo_count)
notify_by_matrix(
event_type = 'issue_created',
+23 -3
View File
@@ -160,9 +160,13 @@
</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 %}
{{ form.photos.label(class="form-label small fw-semibold") }}
{{ form.photos(class="form-control", accept="image/*", id="reportPhotos", multiple=true) }}
<div class="form-text">You can attach up to 5 photos.</div>
<div class="text-danger small mt-1" id="photoLimitMsg" style="display:none;">
Please select no more than 5 photos — only the first 5 will be used.
</div>
{% for e in form.photos.errors %}
<div class="text-danger small mt-1">{{ e }}</div>
{% endfor %}
</div>
@@ -177,5 +181,21 @@
Janitorial Quality Control
</div>
</div>
<script>
(function () {
'use strict';
var input = document.getElementById('reportPhotos');
var msg = document.getElementById('photoLimitMsg');
if (!input) { return; }
input.addEventListener('change', function () {
if (input.files && input.files.length > 5) {
if (msg) { msg.style.display = 'block'; }
input.value = ''; // clear an over-limit selection so they re-pick
} else if (msg) {
msg.style.display = 'none';
}
});
}());
</script>
</body>
</html>
+23 -3
View File
@@ -159,9 +159,13 @@
</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 %}
{{ form.photos.label(class="form-label small fw-semibold") }}
{{ form.photos(class="form-control", accept="image/*", id="reportPhotos", multiple=true) }}
<div class="form-text">You can attach up to 5 photos.</div>
<div class="text-danger small mt-1" id="photoLimitMsg" style="display:none;">
Please select no more than 5 photos — only the first 5 will be used.
</div>
{% for e in form.photos.errors %}
<div class="text-danger small mt-1">{{ e }}</div>
{% endfor %}
</div>
@@ -176,5 +180,21 @@
Janitorial Quality Control
</div>
</div>
<script>
(function () {
'use strict';
var input = document.getElementById('reportPhotos');
var msg = document.getElementById('photoLimitMsg');
if (!input) { return; }
input.addEventListener('change', function () {
if (input.files && input.files.length > 5) {
if (msg) { msg.style.display = 'block'; }
input.value = ''; // clear an over-limit selection so they re-pick
} else if (msg) {
msg.style.display = 'none';
}
});
}());
</script>
</body>
</html>
+4 -4
View File
@@ -315,10 +315,10 @@ class PublicIssueReportForm(FlaskForm):
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).')])
photos = MultipleFileField('Add photos (optional, up to 5)',
validators=[Optional(),
FileAllowed(['jpg', 'jpeg', 'png', 'gif'],
'Images only (jpg, png, gif).')])
website = StringField('Website') # honeypot — must stay empty