Jul 17 - Update Facility/Area QR code report page with anti-duplication reports
This commit is contained in:
@@ -1343,7 +1343,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=...)`. **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. |
|
| 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, honeypot-guarded, and **idempotency-guarded** (`_recent_duplicate_report()` — an identical public report for the same facility/area within `DUPLICATE_REPORT_WINDOW_SECONDS`=60s is silently accepted as success without creating a second issue or saving its photos; the dedup check runs BEFORE `_save_report_photos()` to avoid orphaned uploads). The client also disables the submit button on first tap. 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. |
|
| 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`. |
|
| 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`). |
|
| 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`). |
|
||||||
|
|||||||
+50
-7
@@ -62,6 +62,31 @@ def _save_report_photos(file_list):
|
|||||||
return photo_path, extra_paths
|
return photo_path, extra_paths
|
||||||
|
|
||||||
|
|
||||||
|
#: Window within which an identical public report is treated as a duplicate.
|
||||||
|
DUPLICATE_REPORT_WINDOW_SECONDS = 60
|
||||||
|
|
||||||
|
|
||||||
|
def _recent_duplicate_report(facility_id, area_id, description):
|
||||||
|
"""Return True if an identical public report was just filed.
|
||||||
|
|
||||||
|
Belt-and-suspenders against duplicate submissions (double-taps, JS-disabled
|
||||||
|
clients, retries): if a public issue (reported_by IS NULL) with the same
|
||||||
|
facility/area and identical description was created within the last
|
||||||
|
DUPLICATE_REPORT_WINDOW_SECONDS, treat this one as a duplicate and skip it.
|
||||||
|
"""
|
||||||
|
cutoff = now_eastern() - timedelta(seconds=DUPLICATE_REPORT_WINDOW_SECONDS)
|
||||||
|
q = Issue.query.filter(
|
||||||
|
Issue.reported_by.is_(None),
|
||||||
|
Issue.reported_at >= cutoff,
|
||||||
|
Issue.description == description,
|
||||||
|
)
|
||||||
|
if area_id is not None:
|
||||||
|
q = q.filter(Issue.area_id == area_id)
|
||||||
|
else:
|
||||||
|
q = q.filter(Issue.facility_id == facility_id, Issue.area_id.is_(None))
|
||||||
|
return db.session.query(q.exists()).scalar()
|
||||||
|
|
||||||
|
|
||||||
def _facility_by_token_or_404(token: str) -> Facility:
|
def _facility_by_token_or_404(token: str) -> Facility:
|
||||||
"""Resolve an ACTIVE facility from its public token, else 404."""
|
"""Resolve an ACTIVE facility from its public token, else 404."""
|
||||||
if not token:
|
if not token:
|
||||||
@@ -344,10 +369,6 @@ def report_problem(token):
|
|||||||
return render_template('public/facility.html',
|
return render_template('public/facility.html',
|
||||||
form=form, token=token, **summary), 400
|
form=form, token=token, **summary), 400
|
||||||
|
|
||||||
# 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
|
# Fold optional reporter identity + location into the description; the
|
||||||
# public reporter is not a User, so reported_by stays NULL.
|
# public reporter is not a User, so reported_by stays NULL.
|
||||||
parts = ['[Reported via facility QR code]']
|
parts = ['[Reported via facility QR code]']
|
||||||
@@ -360,6 +381,19 @@ def report_problem(token):
|
|||||||
parts.append(form.description.data.strip())
|
parts.append(form.description.data.strip())
|
||||||
description = '\n'.join(parts)
|
description = '\n'.join(parts)
|
||||||
|
|
||||||
|
# Server-side idempotency: silently accept an identical repeat as success
|
||||||
|
# without creating a second issue (or saving its photos).
|
||||||
|
if _recent_duplicate_report(facility.id, None, description):
|
||||||
|
logger.info('PUBLIC REPORT | duplicate suppressed | facility_id=%s | ip=%s',
|
||||||
|
facility.id, request.remote_addr)
|
||||||
|
flash('Thank you — your report has been received and the team has been notified.',
|
||||||
|
'success')
|
||||||
|
return redirect(url_for('public.facility_summary', token=token))
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
issue = Issue(
|
issue = Issue(
|
||||||
facility_id = facility.id,
|
facility_id = facility.id,
|
||||||
area_id = None,
|
area_id = None,
|
||||||
@@ -416,9 +450,6 @@ def area_report_problem(token):
|
|||||||
return render_template('public/area.html',
|
return render_template('public/area.html',
|
||||||
form=form, token=token, **summary), 400
|
form=form, token=token, **summary), 400
|
||||||
|
|
||||||
# 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 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.
|
# the source. A public reporter is not a User, so reported_by stays NULL.
|
||||||
parts = [f'[Reported via area QR code — {area.name}]']
|
parts = [f'[Reported via area QR code — {area.name}]']
|
||||||
@@ -431,6 +462,18 @@ def area_report_problem(token):
|
|||||||
parts.append(form.description.data.strip())
|
parts.append(form.description.data.strip())
|
||||||
description = '\n'.join(parts)
|
description = '\n'.join(parts)
|
||||||
|
|
||||||
|
# Server-side idempotency: silently accept an identical repeat as success
|
||||||
|
# without creating a second issue (or saving its photos).
|
||||||
|
if _recent_duplicate_report(facility.id, area.id, description):
|
||||||
|
logger.info('PUBLIC REPORT | duplicate suppressed | area_id=%s | facility_id=%s | ip=%s',
|
||||||
|
area.id, facility.id, request.remote_addr)
|
||||||
|
flash('Thank you — your report has been received and the team has been notified.',
|
||||||
|
'success')
|
||||||
|
return redirect(url_for('public.area_summary', token=token))
|
||||||
|
|
||||||
|
# Save up to 5 optional photos (first → photo_path, rest → mobile_photo_paths).
|
||||||
|
photo_path, extra_photos = _save_report_photos(form.photos.data)
|
||||||
|
|
||||||
issue = Issue(
|
issue = Issue(
|
||||||
facility_id = facility.id,
|
facility_id = facility.id,
|
||||||
area_id = area.id,
|
area_id = area.id,
|
||||||
|
|||||||
@@ -124,7 +124,7 @@
|
|||||||
Let the cleaning team know.
|
Let the cleaning team know.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form method="POST"
|
<form method="POST" id="reportForm"
|
||||||
action="{{ url_for('public.area_report_problem', token=token) }}"
|
action="{{ url_for('public.area_report_problem', token=token) }}"
|
||||||
enctype="multipart/form-data" novalidate>
|
enctype="multipart/form-data" novalidate>
|
||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
@@ -171,7 +171,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary w-100">
|
<button type="submit" class="btn btn-primary w-100" id="submitBtn">
|
||||||
<i class="bi bi-send"></i> Submit Report
|
<i class="bi bi-send"></i> Submit Report
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -184,9 +184,11 @@
|
|||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
// Cap photo selection at 5.
|
||||||
var input = document.getElementById('reportPhotos');
|
var input = document.getElementById('reportPhotos');
|
||||||
var msg = document.getElementById('photoLimitMsg');
|
var msg = document.getElementById('photoLimitMsg');
|
||||||
if (!input) { return; }
|
if (input) {
|
||||||
input.addEventListener('change', function () {
|
input.addEventListener('change', function () {
|
||||||
if (input.files && input.files.length > 5) {
|
if (input.files && input.files.length > 5) {
|
||||||
if (msg) { msg.style.display = 'block'; }
|
if (msg) { msg.style.display = 'block'; }
|
||||||
@@ -195,6 +197,28 @@
|
|||||||
msg.style.display = 'none';
|
msg.style.display = 'none';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent duplicate reports: disable the button on first submit so a slow
|
||||||
|
// network can't be double-tapped into multiple identical reports.
|
||||||
|
var form = document.getElementById('reportForm');
|
||||||
|
var btn = document.getElementById('submitBtn');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
if (form.dataset.submitting === '1') { e.preventDefault(); return; }
|
||||||
|
form.dataset.submitting = '1';
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Submitting…';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// After a successful submit we redirect back here with a flash message;
|
||||||
|
// jump to the top so the confirmation is seen and the form isn't re-tapped.
|
||||||
|
if (document.querySelector('.alert')) {
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}
|
||||||
}());
|
}());
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -123,7 +123,7 @@
|
|||||||
Notice something that needs attention? Let the cleaning team know.
|
Notice something that needs attention? Let the cleaning team know.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form method="POST"
|
<form method="POST" id="reportForm"
|
||||||
action="{{ url_for('public.report_problem', token=token) }}"
|
action="{{ url_for('public.report_problem', token=token) }}"
|
||||||
enctype="multipart/form-data" novalidate>
|
enctype="multipart/form-data" novalidate>
|
||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
@@ -170,7 +170,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary w-100">
|
<button type="submit" class="btn btn-primary w-100" id="submitBtn">
|
||||||
<i class="bi bi-send"></i> Submit Report
|
<i class="bi bi-send"></i> Submit Report
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -183,9 +183,11 @@
|
|||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
// Cap photo selection at 5.
|
||||||
var input = document.getElementById('reportPhotos');
|
var input = document.getElementById('reportPhotos');
|
||||||
var msg = document.getElementById('photoLimitMsg');
|
var msg = document.getElementById('photoLimitMsg');
|
||||||
if (!input) { return; }
|
if (input) {
|
||||||
input.addEventListener('change', function () {
|
input.addEventListener('change', function () {
|
||||||
if (input.files && input.files.length > 5) {
|
if (input.files && input.files.length > 5) {
|
||||||
if (msg) { msg.style.display = 'block'; }
|
if (msg) { msg.style.display = 'block'; }
|
||||||
@@ -194,6 +196,28 @@
|
|||||||
msg.style.display = 'none';
|
msg.style.display = 'none';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent duplicate reports: disable the button on first submit so a slow
|
||||||
|
// network can't be double-tapped into multiple identical reports.
|
||||||
|
var form = document.getElementById('reportForm');
|
||||||
|
var btn = document.getElementById('submitBtn');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
if (form.dataset.submitting === '1') { e.preventDefault(); return; }
|
||||||
|
form.dataset.submitting = '1';
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Submitting…';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// After a successful submit we redirect back here with a flash message;
|
||||||
|
// jump to the top so the confirmation is seen and the form isn't re-tapped.
|
||||||
|
if (document.querySelector('.alert')) {
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}
|
||||||
}());
|
}());
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user