Jul 16 - Update facilities QR codes - report can upload up to 5 photos
This commit is contained in:
@@ -279,7 +279,7 @@ Triage of `handler_type` + facility/vendor detail fields on the **update** panel
|
|||||||
| Column | Type | Populated by | Displayed as |
|
| Column | Type | Populated by | Displayed as |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `photo_path` | `VARCHAR(255)` | Web form upload OR first iPad photo | "Photo Evidence" (primary) |
|
| `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 2–5)** | "Photo Evidence" (additional) |
|
||||||
| `result_photos` | `JSON` (`list[str]`) | Web update form file upload — resolution photos | "Resolution Details" |
|
| `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".
|
**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` |
|
| `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` (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)`. |
|
| `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) |
|
| `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 |
|
||||||
| `inspections` | `/inspections` | list, start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect, upload-photo (AJAX) |
|
| `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. |
|
| 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=...)`. 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. |
|
| 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`). |
|
||||||
|
|||||||
+37
-9
@@ -38,6 +38,30 @@ logger = logging.getLogger(__name__)
|
|||||||
bp = Blueprint('public', __name__, url_prefix='/f')
|
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:
|
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:
|
||||||
@@ -320,9 +344,9 @@ 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 optional photo through the shared, magic-byte-validated saver.
|
# Save up to 5 optional photos through the shared, magic-byte-validated
|
||||||
from app.routes.inspections import _save_photo
|
# saver. First → photo_path, the rest → mobile_photo_paths.
|
||||||
photo_path = _save_photo(form.photo.data, subfolder='issue_photos')
|
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.
|
||||||
@@ -342,6 +366,7 @@ def report_problem(token):
|
|||||||
severity = 'medium',
|
severity = 'medium',
|
||||||
description = description,
|
description = description,
|
||||||
photo_path = photo_path,
|
photo_path = photo_path,
|
||||||
|
mobile_photo_paths = extra_photos,
|
||||||
status = 'open',
|
status = 'open',
|
||||||
reported_at = now_eastern(),
|
reported_at = now_eastern(),
|
||||||
reported_by = None,
|
reported_by = None,
|
||||||
@@ -349,8 +374,9 @@ def report_problem(token):
|
|||||||
db.session.add(issue)
|
db.session.add(issue)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
logger.info('PUBLIC REPORT | issue_id=%s | facility_id=%s | ip=%s | photo=%s',
|
_photo_count = (1 if photo_path else 0) + (len(extra_photos) if extra_photos else 0)
|
||||||
issue.id, facility.id, request.remote_addr, bool(photo_path))
|
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).
|
# Reuse the standard issue-created routing (staff + facility customers).
|
||||||
notify_by_matrix(
|
notify_by_matrix(
|
||||||
@@ -390,8 +416,8 @@ 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
|
||||||
|
|
||||||
from app.routes.inspections import _save_photo
|
# Save up to 5 optional photos (first → photo_path, rest → mobile_photo_paths).
|
||||||
photo_path = _save_photo(form.photo.data, subfolder='issue_photos')
|
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.
|
||||||
@@ -411,6 +437,7 @@ def area_report_problem(token):
|
|||||||
severity = 'medium',
|
severity = 'medium',
|
||||||
description = description,
|
description = description,
|
||||||
photo_path = photo_path,
|
photo_path = photo_path,
|
||||||
|
mobile_photo_paths = extra_photos,
|
||||||
status = 'open',
|
status = 'open',
|
||||||
reported_at = now_eastern(),
|
reported_at = now_eastern(),
|
||||||
reported_by = None,
|
reported_by = None,
|
||||||
@@ -418,8 +445,9 @@ def area_report_problem(token):
|
|||||||
db.session.add(issue)
|
db.session.add(issue)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
logger.info('PUBLIC REPORT | issue_id=%s | area_id=%s | facility_id=%s | ip=%s | photo=%s',
|
_photo_count = (1 if photo_path else 0) + (len(extra_photos) if extra_photos else 0)
|
||||||
issue.id, area.id, facility.id, request.remote_addr, bool(photo_path))
|
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(
|
notify_by_matrix(
|
||||||
event_type = 'issue_created',
|
event_type = 'issue_created',
|
||||||
|
|||||||
@@ -160,9 +160,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
{{ form.photo.label(class="form-label small fw-semibold") }}
|
{{ form.photos.label(class="form-label small fw-semibold") }}
|
||||||
{{ form.photo(class="form-control", accept="image/*") }}
|
{{ form.photos(class="form-control", accept="image/*", id="reportPhotos", multiple=true) }}
|
||||||
{% for e in form.photo.errors %}
|
<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>
|
<div class="text-danger small mt-1">{{ e }}</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -177,5 +181,21 @@
|
|||||||
Janitorial Quality Control
|
Janitorial Quality Control
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -159,9 +159,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
{{ form.photo.label(class="form-label small fw-semibold") }}
|
{{ form.photos.label(class="form-label small fw-semibold") }}
|
||||||
{{ form.photo(class="form-control", accept="image/*") }}
|
{{ form.photos(class="form-control", accept="image/*", id="reportPhotos", multiple=true) }}
|
||||||
{% for e in form.photo.errors %}
|
<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>
|
<div class="text-danger small mt-1">{{ e }}</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -176,5 +180,21 @@
|
|||||||
Janitorial Quality Control
|
Janitorial Quality Control
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1
-1
@@ -315,7 +315,7 @@ class PublicIssueReportForm(FlaskForm):
|
|||||||
validators=[Optional(), Length(max=100)])
|
validators=[Optional(), Length(max=100)])
|
||||||
reporter_contact = StringField('Email or phone (optional)',
|
reporter_contact = StringField('Email or phone (optional)',
|
||||||
validators=[Optional(), Length(max=120)])
|
validators=[Optional(), Length(max=120)])
|
||||||
photo = FileField('Add a photo (optional)',
|
photos = MultipleFileField('Add photos (optional, up to 5)',
|
||||||
validators=[Optional(),
|
validators=[Optional(),
|
||||||
FileAllowed(['jpg', 'jpeg', 'png', 'gif'],
|
FileAllowed(['jpg', 'jpeg', 'png', 'gif'],
|
||||||
'Images only (jpg, png, gif).')])
|
'Images only (jpg, png, gif).')])
|
||||||
|
|||||||
Reference in New Issue
Block a user