Aug 17 - Update bulk actions for inspection/issue list

This commit is contained in:
2026-08-17 14:48:20 -04:00
parent 5486145a24
commit 1de15a925b
14 changed files with 801 additions and 48 deletions
+42 -2
View File
@@ -571,9 +571,9 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi
| `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. | | `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` | **Owns BOTH customer roles (Phase 51).** `GET /` list (both roles, role badge + per-role scope column), `GET/POST /new` invite (role select: Customer Director / Customer Inspector — same invitation flow for both), `/set-password/<token>`, `GET /<id>` manage, `/<id>/edit`, `POST /<id>/assignments/add` + `/assignments/<aid>/remove` (**director only** — `CustomerAssignment`), `POST /<id>/contracts` (**inspector only** — replaces the whole `InspectorAssignment` set, rule 59 semantics), `POST /<id>/notifications` (per-account matrix overrides), `POST /<id>/switch-role` (**admin only** — mirrors contracts across, revokes tokens/devices), `POST /<id>/toggle-active`, `POST /<id>/resend-invite`, import CSV | | `customers` | `/customers` | **Owns BOTH customer roles (Phase 51).** `GET /` list (both roles, role badge + per-role scope column), `GET/POST /new` invite (role select: Customer Director / Customer Inspector — same invitation flow for both), `/set-password/<token>`, `GET /<id>` manage, `/<id>/edit`, `POST /<id>/assignments/add` + `/assignments/<aid>/remove` (**director only** — `CustomerAssignment`), `POST /<id>/contracts` (**inspector only** — replaces the whole `InspectorAssignment` set, rule 59 semantics), `POST /<id>/notifications` (per-account matrix overrides), `POST /<id>/switch-role` (**admin only** — mirrors contracts across, revokes tokens/devices), `POST /<id>/toggle-active`, `POST /<id>/resend-invite`, 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), **`POST /bulk`** (bulk export-PDF / request-follow-up / clear-follow-up / delete from the list) |
| `templates` | `/templates` | list, create, edit, delete, form editor, preview | | `templates` | `/templates` | list, create, edit, delete, form editor, preview |
| `issues` | `/issues` | list, view, create, update, verify, comment, follow/unfollow, verification queue, bulk-verify, delete, quick-assign. **verify / bulk-verify / verification-queue are `@issue_manager_required` (admin/director/auditor); delete stays `@supervisor_required` (admin/director).** | | `issues` | `/issues` | list, view, create, update, verify, comment, follow/unfollow, verification queue, bulk-verify, delete, quick-assign, **`POST /bulk`** (bulk assign / status / verify / delete from the list). **verify / bulk-verify / verification-queue are `@issue_manager_required` (admin/director/auditor); delete stays `@supervisor_required` (admin/director).** |
| `notifications` | `/notifications` | list, mark-read, preferences, send-digest (cron), check-sla (cron), cleanup-tokens (cron) | | `notifications` | `/notifications` | list, mark-read, preferences, send-digest (cron), check-sla (cron), cleanup-tokens (cron) |
| `audit` | `/audit` | list (admin only), view, purge | | `audit` | `/audit` | list (admin only), view, purge |
| `reports` | `/reports` | index, facility report, scorecard, CSV/PDF/Excel export, issues-aging, sla-compliance, followup-closure, facility summary PDF | | `reports` | `/reports` | index, facility report, scorecard, CSV/PDF/Excel export, issues-aging, sla-compliance, followup-closure, facility summary PDF |
@@ -612,6 +612,10 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi
So with no DNS work an invite from `jqc.govservicesinc.com` sends `From: "Gov Services QC" <jqc.noreply@ltservicesinc.com>` (branded name, deliverable address). After that domain's SPF `include:` + DKIM are live, add it to `SENDER_AUTHORIZED_DOMAINS` and it upgrades to `<jqc.noreply@govservicesinc.com>` — no code change. Falls back to the bare authenticated sender string for unparseable hosts (localhost, empty). Edit `BRAND_NAMES` / `SENDER_AUTHORIZED_DOMAINS` as brands and DNS come online. See rule 64. So with no DNS work an invite from `jqc.govservicesinc.com` sends `From: "Gov Services QC" <jqc.noreply@ltservicesinc.com>` (branded name, deliverable address). After that domain's SPF `include:` + DKIM are live, add it to `SENDER_AUTHORIZED_DOMAINS` and it upgrades to `<jqc.noreply@govservicesinc.com>` — no code change. Falls back to the bare authenticated sender string for unparseable hosts (localhost, empty). Edit `BRAND_NAMES` / `SENDER_AUTHORIZED_DOMAINS` as brands and DNS come online. See rule 64.
### `decorators.py` — `return_url(fallback)`
Reads the `next` value a list-page action carried (POST body first, then query string), validates it with `safe_redirect_url`, and falls back. This is what makes an edit or delete return to the **filtered** list instead of the bare index. `next` is the FULL list URL — never a reconstructed argument set — so adding a filter to either list page needs no change here. See §18 "List filter preservation".
### `scope.py` ### `scope.py`
`get_customer_scope(user)` — returns `list[int]` facility IDs for customers, `None` for non-customers. `get_customer_scope(user)` — returns `list[int]` facility IDs for customers, `None` for non-customers.
`get_inspector_scope(user)` — returns `list[int]` facility IDs for inspectors (empty list = no assignments = no access), `None` for non-inspectors. Derived from `InspectorAssignment` rows → project → active facilities. `get_inspector_scope(user)` — returns `list[int]` facility IDs for inspectors (empty list = no assignments = no access), `None` for non-inspectors. Derived from `InspectorAssignment` rows → project → active facilities.
@@ -1335,6 +1339,40 @@ Rendered in `dashboard.html` for `current_user.role == 'customer'`. Uses a Boots
- **Customer commenting:** Only shown when `can_customer_comment = is_following or issue.reported_by == current_user.id`. Customer POST bypasses `IssueUpdateForm`; the route sets `is_customer_visible=True` unconditionally. - **Customer commenting:** Only shown when `can_customer_comment = is_following or issue.reported_by == current_user.id`. Customer POST bypasses `IssueUpdateForm`; the route sets `is_customer_visible=True` unconditionally.
- **Read filtering:** `GET issues/view` passes `filter_by(is_customer_visible=True)` to customers; staff receive all comments. **Gated by `COMMENTS_VISIBLE_TO_ALL` (temporary, Aug 2026)** — while that config is true the filter is skipped entirely and customers see every comment. The route passes `comments_open` to the template, which then (a) suppresses the per-comment "Customer visible" / "Staff only" badges, since they would misstate what the customer can actually see, and (b) hides the "Share with customer" tick behind a warning banner reading *"Comments are currently visible to everyone… Do not post internal-only notes here."* The checkbox value is still posted and stored, so flipping the config back restores both the filtering and the badges immediately. - **Read filtering:** `GET issues/view` passes `filter_by(is_customer_visible=True)` to customers; staff receive all comments. **Gated by `COMMENTS_VISIBLE_TO_ALL` (temporary, Aug 2026)** — while that config is true the filter is skipped entirely and customers see every comment. The route passes `comments_open` to the template, which then (a) suppresses the per-comment "Customer visible" / "Staff only" badges, since they would misstate what the customer can actually see, and (b) hides the "Share with customer" tick behind a warning banner reading *"Comments are currently visible to everyone… Do not post internal-only notes here."* The checkbox value is still posted and stored, so flipping the config back restores both the filtering and the badges immediately.
### List filter preservation (Aug 2026)
Filtering a list, then editing or deleting a row, used to dump the user back on the **unfiltered** index. Every list-page action now round-trips the list URL.
**The mechanism, end to end:**
1. `current_url()` — Jinja global registered in `app/__init__.py`, returns `request.full_path` with a bare trailing `?` stripped. The list templates put it in a `<input type="hidden" name="next">` on every action form, and append `?next=` to every link into a detail page.
2. The detail templates (`issues/view.html`, `inspections/view.html`) set `{% set back_url = request.args.get('next') or url_for('<bp>.index') %}` once, and thread it into their own action forms **and** the Back button.
3. `return_url(fallback)` (§8) resolves it after the action; `_view_url(id)` in each blueprint re-attaches `next` when an action redirects back to the detail page, so the chain survives an update.
**Carry the whole URL, not the filters.** The old unfollow form rebuilt `next` with an 11-argument `url_for(...)` that had to be hand-edited whenever a filter was added — and silently dropped any filter nobody remembered. `current_url()` cannot drift.
`safe_redirect_url` still guards every hop, so a crafted `next=https://evil.com` falls back to the index (rule 15) — verified.
The inspections list also keeps its older `sessionStorage['insp_list_back_url']` fallback for links created before `next` existed, but a server-provided `next` **wins**: `view.html` emits `var hasNext = true;` and skips the sessionStorage read, otherwise a stale stored URL would override the list the page was actually opened from.
### Bulk actions on the list pages (Aug 2026)
Both list pages carry a bulk toolbar above the table with per-row checkboxes.
| Page | Actions | Permission |
|---|---|---|
| Issues (`POST /issues/bulk`) | assign, set status, verify & close, delete | assign/status/verify: admin/director/auditor · delete: admin/director |
| Inspections (`POST /inspections/bulk`) | export selected to PDF, request follow-up (shared note), clear follow-up, delete | export: anyone who can see the list · rest: admin/director |
**The toolbar form sits OUTSIDE the table — this is load-bearing (rule 91).** Row checkboxes join it with the HTML5 `form="issuesBulkForm"` attribute rather than being wrapped by it. Wrapping the table would nest the per-row delete/unfollow forms inside the bulk form, and browsers silently discard nested forms (rule 9) — the row actions would stop working with no error anywhere.
**Shared partials, not four copies.** Both lists have classic *and* modern variants, so the markup lives in `templates/partials/bulk_issues_toolbar.html`, `bulk_inspections_toolbar.html` and `bulk_select_js.html`; each of the four list templates includes them. The JS is generic (`.bulk-check`, `.bulk-check-all`, `.bulk-count`, `[data-bulk-action]`, `[data-bulk-confirm]`) and supports shift-click range selection; it disables the action buttons while nothing is selected, so an empty POST can't cost a page round trip.
**Partial-failure policy: act, skip, report exact counts** — never block the batch on one ineligible row, never silently drop rows. `_flash_bulk()` in each blueprint emits the one message shape ("3 issues verified and closed. 2 skipped (not awaiting verification)."). Rows are skipped when the action does not apply (already in that status, not submitted yet, already flagged); *permission* is checked per action, up front, not per row.
**The inspections bulk route re-applies facility scope to the submitted ids.** The list only ever shows in-scope rows, but the id list arrives in the POST body and is not trusted — without the re-check a crafted request could name any inspection in the system. Issue bulk actions are all manager-level (org-wide access), so they have no per-row scope question.
**Deletes remove DB rows first, files second** (both blueprints). An orphaned file is recoverable; a file deleted out from under a surviving row is not. `_collect_inspection_photos()` was factored out of the single-delete path so bulk and single delete cannot drift — a miss there leaks storage silently, forever.
### Inspection List Filters ### Inspection List Filters
`inspections.index()` accepts five additional query params: `date_from`, `date_to` (ISO date strings), `score_min`, `score_max` (0100 floats), `inspector_id` (int). Inspector filter is suppressed when the viewer has the `inspector` role (they always see their own only). The `inspectors` variable is passed to the template only for non-inspector roles so the dropdown is conditionally rendered. `inspections.index()` accepts five additional query params: `date_from`, `date_to` (ISO date strings), `score_min`, `score_max` (0100 floats), `inspector_id` (int). Inspector filter is suppressed when the viewer has the `inspector` role (they always see their own only). The `inspectors` variable is passed to the template only for non-inspector roles so the dropdown is conditionally rendered.
@@ -1577,6 +1615,8 @@ timeout = 30
| 85 | **`next_due_date` is mutable state, `end_date` is a fixed boundary — never conflate them** | `fulfill()` rewrites `next_due_date` after every completed inspection; `end_date` is set by the manager and never touched by the app. The old single label "Start / Due Date" said both at once, which is what users reported as confusing. The label now follows context — `form.next_due_date.label.text` is set to "Start Date" in `create()` and "Next Due Date" in `edit()`. Do not rename the `next_due_date` column to match a label: it is indexed, it is the API payload key the iPad decodes, and the reminder cron filters on it. | | 85 | **`next_due_date` is mutable state, `end_date` is a fixed boundary — never conflate them** | `fulfill()` rewrites `next_due_date` after every completed inspection; `end_date` is set by the manager and never touched by the app. The old single label "Start / Due Date" said both at once, which is what users reported as confusing. The label now follows context — `form.next_due_date.label.text` is set to "Start Date" in `create()` and "Next Due Date" in `edit()`. Do not rename the `next_due_date` column to match a label: it is indexed, it is the API payload key the iPad decodes, and the reminder cron filters on it. |
| 87 | **Never write `role == 'inspector'` — use `user.is_inspector` (`User.INSPECTOR_ROLES`)** | phase49 added `external_inspector`, which must behave as an inspector everywhere. An equality check silently drops it into the *privileged* branch of every `if inspector: scope … else: org-wide` block — i.e. a third-party inspector would see **every contract in the system**. This is a fail-OPEN mistake: nothing errors, the data just leaks. The sweep converted ~44 Python sites and 7 template sites; the only surviving `== 'inspector'` literals are the matrix docstring, the `MATRIX_DEFAULTS` mirror comprehension, and the default-checked box in `admin/broadcast.html`. Query-level checks use `User.role.in_(User.INSPECTOR_ROLES)` (never `filter_by(role='inspector')`). A **new** `app/api/*` blueprint's `_ALLOWED_ROLES` must include `external_inspector`, same as rule 79 requires for `auditor`. | | 87 | **Never write `role == 'inspector'` — use `user.is_inspector` (`User.INSPECTOR_ROLES`)** | phase49 added `external_inspector`, which must behave as an inspector everywhere. An equality check silently drops it into the *privileged* branch of every `if inspector: scope … else: org-wide` block — i.e. a third-party inspector would see **every contract in the system**. This is a fail-OPEN mistake: nothing errors, the data just leaks. The sweep converted ~44 Python sites and 7 template sites; the only surviving `== 'inspector'` literals are the matrix docstring, the `MATRIX_DEFAULTS` mirror comprehension, and the default-checked box in `admin/broadcast.html`. Query-level checks use `User.role.in_(User.INSPECTOR_ROLES)` (never `filter_by(role='inspector')`). A **new** `app/api/*` blueprint's `_ALLOWED_ROLES` must include `external_inspector`, same as rule 79 requires for `auditor`. |
| 88 | **`app/enrollment/` writes no DB row and has exactly ONE read — keep the vertical slice sealed** | The enrollment form describes accounts that do NOT exist yet (no contract, facility or user to key a row against), so it stores flat JSON in `ENROLLMENT_DIR` and owns its own templates. The single permitted model access is `mailer._admin_recipients()` reading active `admin` users to address the new-enrollment alert — function-local, read-only, and guarded so a DB failure cannot break a submission. Adding a model/migration for enrollment, or letting the public POST **create** Users, would couple an unauthenticated endpoint to the account system — the exact thing the separation buys. If enrollment must ever provision accounts, do it as a separate admin-triggered action that reads a stored submission. Submission ids are filesystem paths: validate against `_ID_RE` before every open (path traversal). See §24. | | 88 | **`app/enrollment/` writes no DB row and has exactly ONE read — keep the vertical slice sealed** | The enrollment form describes accounts that do NOT exist yet (no contract, facility or user to key a row against), so it stores flat JSON in `ENROLLMENT_DIR` and owns its own templates. The single permitted model access is `mailer._admin_recipients()` reading active `admin` users to address the new-enrollment alert — function-local, read-only, and guarded so a DB failure cannot break a submission. Adding a model/migration for enrollment, or letting the public POST **create** Users, would couple an unauthenticated endpoint to the account system — the exact thing the separation buys. If enrollment must ever provision accounts, do it as a separate admin-triggered action that reads a stored submission. Submission ids are filesystem paths: validate against `_ID_RE` before every open (path traversal). See §24. |
| 91 | **A bulk-action form must live OUTSIDE the table; row checkboxes join it via the HTML5 `form=` attribute** | Wrapping the table in the bulk form nests the per-row delete/unfollow forms inside it, and browsers **silently discard** nested forms (rule 9) — the row buttons would post nothing, with no console error and no server log. `<form id="issuesBulkForm">` sits above the table and each checkbox carries `form="issuesBulkForm"`. Same for `inspectionsBulkForm`. Applies to all four list templates (classic + modern). |
| 92 | **Bulk deletes: DB rows first, storage files second** | Collect the keys, `db.session.delete()` every row, `commit()`, and only then `storage.delete()`. Deleting files first means a failed/rolled-back commit leaves surviving rows pointing at missing photos. `_collect_inspection_photos()` is shared by the single and bulk inspection delete paths precisely so the two cannot drift — a key missed there is an invisible permanent storage leak. |
| 89 | **`User.CUSTOMER_ROLES` is for ACCOUNT MANAGEMENT; `role == 'customer'` is for CAPABILITY — never swap them** | The inverse of rule 87, and it fails in both directions. Widening a capability check to `CUSTOMER_ROLES` hands a third-party Customer Inspector the customer portal (fail-OPEN, nothing errors). Narrowing an account-management check back to `'customer'` strands every Customer Inspector in a page that no longer lists or edits them (fail-closed, but invisible until someone looks for a missing account). `CUSTOMER_ROLES` / `is_customer_account` appear ONLY in: the `/customers` list query, its route guards, and the `auth.list_users` exclusion. Everything else — portal gates, `@customer_required`, `get_customer_scope()`, support chat, `notify_customers_for_facility()`, the customer branch of every `app/api/*` scope check — keeps the equality test, because a Customer Inspector is an **inspector** there (rule 87 already routes it correctly). | | 89 | **`User.CUSTOMER_ROLES` is for ACCOUNT MANAGEMENT; `role == 'customer'` is for CAPABILITY — never swap them** | The inverse of rule 87, and it fails in both directions. Widening a capability check to `CUSTOMER_ROLES` hands a third-party Customer Inspector the customer portal (fail-OPEN, nothing errors). Narrowing an account-management check back to `'customer'` strands every Customer Inspector in a page that no longer lists or edits them (fail-closed, but invisible until someone looks for a missing account). `CUSTOMER_ROLES` / `is_customer_account` appear ONLY in: the `/customers` list query, its route guards, and the `auth.list_users` exclusion. Everything else — portal gates, `@customer_required`, `get_customer_scope()`, support chat, `notify_customers_for_facility()`, the customer branch of every `app/api/*` scope check — keeps the equality test, because a Customer Inspector is an **inspector** there (rule 87 already routes it correctly). |
| 90 | **A per-account notification opt-IN must survive a globally-OFF column** | `notify_by_matrix()` skips a role column early when the matrix says off. For the two customer columns that early `continue` has to also ask whether anyone opted in (`any(overrides.values())`), or the override saves, displays as on, and never sends — a silent failure with no error anywhere. Equally, `notify_customers_for_facility()` re-queries recipients from assignment rows, so `notify_by_matrix()` must hand it `allowed_user_ids` or the facility-scoped path bypasses every override. Both halves are needed; either one alone leaves a hole. See §11. | | 90 | **A per-account notification opt-IN must survive a globally-OFF column** | `notify_by_matrix()` skips a role column early when the matrix says off. For the two customer columns that early `continue` has to also ask whether anyone opted in (`any(overrides.values())`), or the override saves, displays as on, and never sends — a silent failure with no error anywhere. Equally, `notify_customers_for_facility()` re-queries recipients from assignment rows, so `notify_by_matrix()` must hand it `allowed_user_ids` or the facility-scoped path bypasses every override. Both halves are needed; either one alone leaves a hole. See §11. |
| 81 | **Photo timestamp/geo overlay is burned at UPLOAD, never on `PATCH /issues/<id>/photos`** | That PATCH receives only path strings — the bytes are already in storage and the payload carries no capture metadata. Burning there would need a read-modify-write per key plus an overwrite-in-place primitive (`storage.save()` mints a NEW uuid key, and §22 requires key == DB path), and would risk a **double burn** since the endpoint is deliberately idempotent/retry-safe (rule 45). Stamp in `POST /photos/upload`, where the raw bytes + EXIF are in hand and each call writes exactly one already-stamped object. Stamping failures must always fall back to storing the ORIGINAL bytes — never lose a photo to a stamping bug. See §23. | | 81 | **Photo timestamp/geo overlay is burned at UPLOAD, never on `PATCH /issues/<id>/photos`** | That PATCH receives only path strings — the bytes are already in storage and the payload carries no capture metadata. Burning there would need a read-modify-write per key plus an overwrite-in-place primitive (`storage.save()` mints a NEW uuid key, and §22 requires key == DB path), and would risk a **double burn** since the endpoint is deliberately idempotent/retry-safe (rule 45). Stamp in `POST /photos/upload`, where the raw bytes + EXIF are in hand and each call writes exactly one already-stamped object. Stamping failures must always fall back to storing the ORIGINAL bytes — never lose a photo to a stamping bug. See §23. |
+9
View File
@@ -143,6 +143,15 @@ def create_app(config_name='default'):
from app.utils import storage as _storage from app.utils import storage as _storage
app.jinja_env.globals['media_url'] = _storage.media_url app.jinja_env.globals['media_url'] = _storage.media_url
# Current page URL including its query string — what list pages hand to
# their actions as `next` so filters survive an edit/delete round trip
# (see utils/decorators.return_url). full_path always appends '?', which
# is harmless but makes for ugly links, so strip a bare trailing one.
def _current_url():
from flask import request
return request.full_path.rstrip('?') if request else ''
app.jinja_env.globals['current_url'] = _current_url
# ── Design A/B test wiring (phase48) ────────────────────────────────── # ── Design A/B test wiring (phase48) ──────────────────────────────────
# Index the modern/ override templates once at boot, so get_template() # Index the modern/ override templates once at boot, so get_template()
# never has to touch the filesystem per request. # never has to touch the filesystem per request.
+234 -22
View File
@@ -15,7 +15,7 @@ from app.models.project import Project
from app.models.issue import Issue from app.models.issue import Issue
from app.models.user import User from app.models.user import User
from app.utils.forms import StartInspectionForm, IssueForm from app.utils.forms import StartInspectionForm, IssueForm
from app.utils.decorators import supervisor_required from app.utils.decorators import supervisor_required, return_url
from app.utils.pdf_export import generate_inspection_pdf, generate_inspections_list_pdf from app.utils.pdf_export import generate_inspection_pdf, generate_inspections_list_pdf
from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix
from app.models.notification import ( from app.models.notification import (
@@ -477,7 +477,7 @@ def execute(inspection_id):
return redirect(url_for('inspections.index')) return redirect(url_for('inspections.index'))
if inspection.status == 'completed': if inspection.status == 'completed':
return redirect(url_for('inspections.view', inspection_id=inspection_id)) return redirect(_view_url(inspection_id))
template = inspection.template template = inspection.template
form_fields = template.get_form_schema() form_fields = template.get_form_schema()
@@ -600,7 +600,7 @@ def execute(inspection_id):
f'status=completed; score={score}') f'status=completed; score={score}')
flash('Inspection submitted successfully!', 'success') flash('Inspection submitted successfully!', 'success')
return redirect(url_for('inspections.view', inspection_id=inspection_id)) return redirect(_view_url(inspection_id))
else: else:
_save_draft(inspection, responses) _save_draft(inspection, responses)
@@ -1220,6 +1220,231 @@ def export_pdf(inspection_id):
# ── Flag / clear follow-up required ────────────────────────────────────────── # ── Flag / clear follow-up required ──────────────────────────────────────────
def _view_url(inspection_id):
"""inspections.view URL that carries the list `next` through.
Actions posted from the detail page redirect back to that same page;
re-attaching `next` is what keeps its Back button (and the next action)
pointed at the filtered list the user arrived from.
"""
nxt = request.form.get('next') or request.args.get('next')
if nxt:
return url_for('inspections.view', inspection_id=inspection_id, next=nxt)
return url_for('inspections.view', inspection_id=inspection_id)
def _collect_inspection_photos(inspection):
"""Relative storage keys owned by an inspection, for cleanup after delete.
Two sources: image field values inside the submitted form data (stored as
`uploads/...` strings in the notes JSON), and the primary photo of each
issue flagged during the inspection. Shared by the single and bulk delete
paths so they cannot drift a miss here leaves orphaned files in storage
forever, and it is invisible.
"""
paths = []
if inspection.notes:
try:
notes_data = json.loads(inspection.notes)
form_data = notes_data.get('_form_data', {}) if isinstance(notes_data, dict) else {}
for val in form_data.values():
if isinstance(val, str) and val.startswith('uploads/'):
paths.append(val)
except (json.JSONDecodeError, TypeError):
pass
for issue in inspection.issues.all():
if issue.photo_path:
paths.append(issue.photo_path)
return paths
# ── Bulk actions from the inspections list ───────────────────────────────────
@bp.route('/bulk', methods=['POST'])
@login_required
def bulk_action():
"""Apply one action to every ticked inspection on the list page.
Partial-failure policy: act on every eligible row, skip the rest, and
report exact counts. Permission is checked per ACTION (all are
admin/director level except the PDF export, which anyone who can see the
list may run); `skipped` therefore means "this row was not in a state the
action applies to".
"""
back = return_url(url_for('inspections.index'))
action = request.form.get('action', '')
ids = request.form.getlist('inspection_ids', type=int)
if not ids:
flash('No inspections selected.', 'warning')
return redirect(back)
supervisor = current_user.role in ('admin', 'director')
allowed = {
'export': True, # read-only, already scoped below
'delete': supervisor,
'flag_followup': supervisor,
'clear_followup': supervisor,
}
if action not in allowed:
flash('Unknown bulk action.', 'danger')
return redirect(back)
if not allowed[action]:
flash('You do not have permission for that bulk action.', 'danger')
return redirect(back)
q = Inspection.query.options(
joinedload(Inspection.facility),
joinedload(Inspection.template),
joinedload(Inspection.inspector),
).filter(Inspection.id.in_(ids))
# Re-apply the viewer's facility scope to the SELECTED ids. The list page
# only ever shows in-scope rows, but the id list arrives in the POST body
# and must not be trusted — a crafted request could otherwise name any
# inspection in the system.
if current_user.is_inspector:
fids = get_inspector_scope(current_user) or []
q = q.filter(Inspection.facility_id.in_(fids)) if fids else q.filter(False)
elif current_user.role == 'customer':
fids = get_customer_scope(current_user) or []
q = q.filter(Inspection.facility_id.in_(fids)) if fids else q.filter(False)
inspections = q.order_by(Inspection.inspection_date.desc()).all()
out_of_scope = len(ids) - len(inspections)
changed = 0
skipped = out_of_scope
# ── Export selected to PDF ───────────────────────────────────────────
if action == 'export':
if not inspections:
flash('None of the selected inspections are available to you.', 'warning')
return redirect(back)
from flask import Response
pdf = generate_inspections_list_pdf(
inspections,
f'Selected inspections ({len(inspections)})',
)
log_action(ACTION_EXPORT, 'Inspection', None, 'bulk PDF export',
f'ids={[i.id for i in inspections]}')
return Response(
pdf,
mimetype='application/pdf',
headers={'Content-Disposition':
'attachment; filename="selected_inspections.pdf"'},
)
# ── Delete ───────────────────────────────────────────────────────────
if action == 'delete':
from app.utils import storage
photo_paths = []
for insp in inspections:
photo_paths.extend(_collect_inspection_photos(insp))
log_action(ACTION_DELETE, 'Inspection', insp.id,
f'{insp.template.name if insp.template else ""} @ '
f'{insp.facility.name if insp.facility else ""}',
f'bulk deleted by {current_user.username}')
db.session.delete(insp)
changed += 1
db.session.commit()
# Files only after the rows are gone — an orphaned file is recoverable,
# a deleted file belonging to a surviving row is not.
for rel_path in photo_paths:
storage.delete(rel_path)
_flash_bulk(changed, skipped, 'permanently deleted')
# ── Request follow-up ────────────────────────────────────────────────
elif action == 'flag_followup':
note = request.form.get('follow_up_note', '').strip() or None
for insp in inspections:
# Same two guards as the single-inspection route: nothing to follow
# up on before submission, and a repeat request must not overwrite
# the pending one's note or attribution.
if insp.status != 'completed' or insp.follow_up_required:
skipped += 1
continue
insp.follow_up_required = True
insp.follow_up_note = note
insp.follow_up_requested_by = current_user.id
insp.follow_up_requested_at = now_eastern()
changed += 1
db.session.commit()
for insp in inspections:
if insp.follow_up_requested_by != current_user.id or not insp.follow_up_required:
continue
body = (f'{current_user.display_name} has requested a follow-up '
f're-inspection of "{insp.template.name if insp.template else ""}" '
f'at {insp.facility.name if insp.facility else ""}.'
+ (f' Note: {note}' if note else ''))
inspector = db.session.get(User, insp.inspector_id)
if inspector and inspector.id != current_user.id:
notify(
recipient = inspector,
title = f'Follow-Up Required: Inspection #{insp.id}',
body = body,
link = url_for('inspections.view', inspection_id=insp.id),
inspection_id = insp.id,
event_type = EVENT_INSPECTION_DONE,
send_email = True,
)
# Through the matrix, not straight to managers — rule 73, so
# per-contract recipients fire here exactly as they do for a
# single request.
notify_by_matrix(
event_type = EVENT_FOLLOWUP_REQUESTED,
title = f'Follow-Up Requested: Inspection #{insp.id}',
body = body,
link = url_for('inspections.view', inspection_id=insp.id),
inspection_id = insp.id,
facility_id = insp.facility_id,
exclude_user_ids = {current_user.id,
inspector.id if inspector else None} - {None},
)
log_action(ACTION_UPDATE, 'Inspection', insp.id,
f'{insp.template.name if insp.template else ""}',
f'bulk follow_up_required=True by {current_user.username}')
db.session.commit() # notify() does not commit — rule 70
_flash_bulk(changed, skipped, 'flagged for follow-up',
skip_reason='not submitted, or already flagged')
# ── Clear follow-up ──────────────────────────────────────────────────
elif action == 'clear_followup':
for insp in inspections:
if not insp.follow_up_required:
skipped += 1
continue
insp.follow_up_required = False
insp.follow_up_note = None
insp.follow_up_requested_by = None
insp.follow_up_requested_at = None
changed += 1
log_action(ACTION_UPDATE, 'Inspection', insp.id,
f'{insp.template.name if insp.template else ""}',
f'bulk follow_up cleared by {current_user.username}')
db.session.commit()
_flash_bulk(changed, skipped, 'cleared of the follow-up flag',
skip_reason='not flagged')
current_app.logger.info(
'INSPECTIONS | bulk | action=%s user=%s selected=%s changed=%s skipped=%s',
action, current_user.username, len(ids), changed, skipped,
)
return redirect(back)
def _flash_bulk(changed, skipped, verb, skip_reason='no change needed'):
"""One consistent result message for every bulk action."""
if not changed and not skipped:
flash('Nothing to do.', 'info')
return
parts = [f'{changed} inspection{"s" if changed != 1 else ""} {verb}']
if skipped:
parts.append(f'{skipped} skipped ({skip_reason})')
flash('. '.join(parts) + '.', 'success' if changed else 'warning')
@bp.route('/<int:inspection_id>/flag-followup', methods=['POST']) @bp.route('/<int:inspection_id>/flag-followup', methods=['POST'])
@login_required @login_required
def flag_followup(inspection_id): def flag_followup(inspection_id):
@@ -1245,12 +1470,12 @@ def flag_followup(inspection_id):
# Nothing to follow up on until the inspection has been submitted. # Nothing to follow up on until the inspection has been submitted.
if inspection.status != 'completed': if inspection.status != 'completed':
flash('You can only request a follow-up on a completed inspection.', 'warning') flash('You can only request a follow-up on a completed inspection.', 'warning')
return redirect(url_for('inspections.view', inspection_id=inspection_id)) return redirect(_view_url(inspection_id))
# Don't let a repeat request overwrite the note/attribution of a pending # Don't let a repeat request overwrite the note/attribution of a pending
# one — the flag is already raised and staff are already on it. # one — the flag is already raised and staff are already on it.
if inspection.follow_up_required: if inspection.follow_up_required:
flash('A follow-up has already been requested for this inspection.', 'info') flash('A follow-up has already been requested for this inspection.', 'info')
return redirect(url_for('inspections.view', inspection_id=inspection_id)) return redirect(_view_url(inspection_id))
elif current_user.role not in ('admin', 'director'): elif current_user.role not in ('admin', 'director'):
abort(403) abort(403)
@@ -1312,7 +1537,7 @@ def flag_followup(inspection_id):
flash('Follow-up re-inspection requested. The team has been notified.', 'success') flash('Follow-up re-inspection requested. The team has been notified.', 'success')
else: else:
flash('Follow-up inspection required flag set.', 'warning') flash('Follow-up inspection required flag set.', 'warning')
return redirect(url_for('inspections.view', inspection_id=inspection_id)) return redirect(_view_url(inspection_id))
@bp.route('/<int:inspection_id>/clear-followup', methods=['POST']) @bp.route('/<int:inspection_id>/clear-followup', methods=['POST'])
@@ -1332,7 +1557,7 @@ def clear_followup(inspection_id):
f'{inspection.template.name} @ {inspection.facility.name}', f'{inspection.template.name} @ {inspection.facility.name}',
'follow_up_required=False (cleared)') 'follow_up_required=False (cleared)')
flash('Follow-up flag cleared.', 'success') flash('Follow-up flag cleared.', 'success')
return redirect(url_for('inspections.view', inspection_id=inspection_id)) return redirect(_view_url(inspection_id))
# ── Start a re-inspection (linked to parent) ────────────────────────────────── # ── Start a re-inspection (linked to parent) ──────────────────────────────────
@@ -1378,20 +1603,7 @@ def delete(inspection_id):
template_name = inspection.template.name template_name = inspection.template.name
inspector_name = inspection.inspector.username inspector_name = inspection.inspector.username
photo_paths = [] photo_paths = _collect_inspection_photos(inspection)
if inspection.notes:
try:
notes_data = json.loads(inspection.notes)
form_data = notes_data.get('_form_data', {}) if isinstance(notes_data, dict) else {}
for val in form_data.values():
if isinstance(val, str) and val.startswith('uploads/'):
photo_paths.append(val)
except (json.JSONDecodeError, TypeError):
pass
for issue in inspection.issues.all():
if issue.photo_path:
photo_paths.append(issue.photo_path)
db.session.delete(inspection) db.session.delete(inspection)
db.session.commit() db.session.commit()
@@ -1418,4 +1630,4 @@ def delete(inspection_id):
f'has been permanently deleted.', f'has been permanently deleted.',
'success' 'success'
) )
return redirect(url_for('inspections.index')) return redirect(return_url(url_for('inspections.index')))
+201 -13
View File
@@ -15,7 +15,8 @@ from app.models.notification import (
EVENT_CUSTOMER_ISSUE_UPDATED, EVENT_CUSTOMER_ISSUE_UPDATED,
) )
from app.utils.forms import IssueForm, IssueUpdateForm from app.utils.forms import IssueForm, IssueUpdateForm
from app.utils.decorators import supervisor_required, issue_manager_required from app.utils.decorators import (supervisor_required, issue_manager_required,
return_url)
from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT
from app.utils.pdf_export import generate_issues_list_pdf from app.utils.pdf_export import generate_issues_list_pdf
@@ -430,7 +431,7 @@ def view(issue_id):
comment_body = request.form.get('update_notes', '').strip() comment_body = request.form.get('update_notes', '').strip()
if not comment_body: if not comment_body:
flash('Comment cannot be empty.', 'warning') flash('Comment cannot be empty.', 'warning')
return redirect(url_for('issues.view', issue_id=issue_id)) return redirect(_view_url(issue_id))
comment = IssueComment( comment = IssueComment(
issue_id=issue.id, issue_id=issue.id,
user_id=current_user.id, user_id=current_user.id,
@@ -444,7 +445,7 @@ def view(issue_id):
f'#{issue.id}', f'#{issue.id}',
'customer comment added') 'customer comment added')
flash('Comment posted.', 'success') flash('Comment posted.', 'success')
return redirect(url_for('issues.view', issue_id=issue_id)) return redirect(_view_url(issue_id))
form = IssueUpdateForm(obj=issue) form = IssueUpdateForm(obj=issue)
staff = User.query.filter(User.role.in_(['director', 'inspector', 'external_inspector', 'auditor'])).order_by(User.username).all() staff = User.query.filter(User.role.in_(['director', 'inspector', 'external_inspector', 'auditor'])).order_by(User.username).all()
@@ -685,7 +686,7 @@ def view(issue_id):
f'#{issue.id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else ''}', f'#{issue.id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else ''}',
f'status={issue.status}; handler={issue.handler_type}; assigned_to={issue.assigned_to}') f'status={issue.status}; handler={issue.handler_type}; assigned_to={issue.assigned_to}')
flash('Issue updated.', 'success') flash('Issue updated.', 'success')
return redirect(url_for('issues.view', issue_id=issue_id)) return redirect(_view_url(issue_id))
is_following = issue.is_followed_by(current_user) is_following = issue.is_followed_by(current_user)
@@ -728,7 +729,7 @@ def follow(issue_id):
flash('You are now following this issue and will receive notifications for any updates.', 'success') flash('You are now following this issue and will receive notifications for any updates.', 'success')
else: else:
flash('You are already following this issue.', 'info') flash('You are already following this issue.', 'info')
return redirect(url_for('issues.view', issue_id=issue_id)) return redirect(_view_url(issue_id))
# ── Unfollow ────────────────────────────────────────────────────────────────── # ── Unfollow ──────────────────────────────────────────────────────────────────
@@ -878,7 +879,7 @@ def create():
) )
db.session.commit() db.session.commit()
flash('Issue created.', 'success') flash('Issue created.', 'success')
return redirect(url_for('issues.index')) return redirect(return_url(url_for('issues.index')))
return render_template('issues/form.html', form=form, title='Log New Issue', return render_template('issues/form.html', form=form, title='Log New Issue',
projects=projects, selected_project_id=selected_project_id) projects=projects, selected_project_id=selected_project_id)
@@ -897,7 +898,7 @@ def verify(issue_id):
if issue.status not in ('resolved', 'pending_verification'): if issue.status not in ('resolved', 'pending_verification'):
flash('Only resolved or pending-verification issues can be verified.', 'warning') flash('Only resolved or pending-verification issues can be verified.', 'warning')
return redirect(url_for('issues.view', issue_id=issue_id)) return redirect(_view_url(issue_id))
note = request.form.get('verification_note', '').strip() or None note = request.form.get('verification_note', '').strip() or None
@@ -917,7 +918,21 @@ def verify(issue_id):
f'#{issue_id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else ''}', f'#{issue_id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else ''}',
f'verified_by={current_user.username}') f'verified_by={current_user.username}')
flash(f'Issue #{issue_id} verified and closed.', 'success') flash(f'Issue #{issue_id} verified and closed.', 'success')
return redirect(url_for('issues.view', issue_id=issue_id)) return redirect(_view_url(issue_id))
def _view_url(issue_id):
"""issues.view URL that carries the list `next` through.
An update posted from the detail page redirects back to that same detail
page; without re-attaching `next`, the Back button would lose the filters
the user arrived with and the next action from this page would too. Only
added when there is something to carry, so ordinary links stay clean.
"""
nxt = request.form.get('next') or request.args.get('next')
if nxt:
return url_for('issues.view', issue_id=issue_id, next=nxt)
return url_for('issues.view', issue_id=issue_id)
@bp.route('/bulk-verify', methods=['POST']) @bp.route('/bulk-verify', methods=['POST'])
@@ -952,7 +967,180 @@ def bulk_verify():
f'bulk_verified_by={current_user.username}') f'bulk_verified_by={current_user.username}')
flash(f'{verified_count} issue{"s" if verified_count != 1 else ""} verified and closed.', 'success') flash(f'{verified_count} issue{"s" if verified_count != 1 else ""} verified and closed.', 'success')
return redirect(url_for('issues.verification_queue')) # Reachable from BOTH the verification queue and the issues list, so honour
# the caller's `next` and fall back to the queue as before.
return redirect(return_url(url_for('issues.verification_queue')))
# ── Bulk actions from the issues list ────────────────────────────────────────
#: Statuses a bulk status change may set, and what an issue must already be in
#: for the change to mean anything. Moving an issue to the state it is already
#: in is a no-op, so it counts as skipped rather than changed.
_BULK_STATUSES = ('open', 'in_progress', 'resolved', 'pending_verification')
@bp.route('/bulk', methods=['POST'])
@login_required
def bulk_action():
"""Apply one action to every ticked issue on the list page.
Partial-failure policy (matches bulk_verify): act on every eligible row,
skip the rest, and report exact counts never silently drop rows, and
never let one ineligible row block the batch.
Permission is checked per ACTION here rather than per row: all four actions
are manager-level, and the roles that hold them have org-wide issue access,
so there is no per-row scope question to answer. `skipped` therefore only
ever means "this row was not in a state the action applies to".
"""
back = return_url(url_for('issues.index'))
action = request.form.get('action', '')
ids = request.form.getlist('issue_ids', type=int)
if not ids:
flash('No issues selected.', 'warning')
return redirect(back)
manager = current_user.role in ('admin', 'director', 'auditor')
deleter = current_user.role in ('admin', 'director')
allowed = {
'assign': manager,
'status': manager,
'verify': manager,
'delete': deleter,
}
if action not in allowed:
flash('Unknown bulk action.', 'danger')
return redirect(back)
if not allowed[action]:
flash('You do not have permission for that bulk action.', 'danger')
return redirect(back)
issues = [i for i in (db.session.get(Issue, i_id) for i_id in ids) if i is not None]
missing = len(ids) - len(issues)
changed = 0
skipped = missing
# ── Assign ───────────────────────────────────────────────────────────
if action == 'assign':
raw = request.form.get('assigned_to', '')
user = None
if raw and raw != '0':
user = db.session.get(User, int(raw)) if raw.isdigit() else None
if user is None:
flash('That user no longer exists.', 'danger')
return redirect(back)
for issue in issues:
if issue.assigned_to == (user.id if user else None):
skipped += 1
continue
issue.assigned_to = user.id if user else None
changed += 1
db.session.commit()
for issue in issues:
if issue.assigned_to == (user.id if user else None) and user:
notify(
recipient = user,
title = f'Issue #{issue.id} assigned to you',
body = (f'{issue.severity.title()}-severity issue at '
f'{issue.resolved_facility.name if issue.resolved_facility else ""}: '
f'{issue.description[:120]}'),
link = url_for('issues.view', issue_id=issue.id),
issue_id = issue.id,
event_type = EVENT_ISSUE_ASSIGNED,
send_email = True,
)
db.session.commit() # notify() does not commit — rule 70
label = user.display_name if user else 'Unassigned'
log_action(ACTION_UPDATE, 'Issue', None, f'bulk assign → {label}',
f'ids={[i.id for i in issues]}; changed={changed}')
_flash_bulk(changed, skipped, f'assigned to {label}')
# ── Status ───────────────────────────────────────────────────────────
elif action == 'status':
new_status = request.form.get('status', '')
if new_status not in _BULK_STATUSES:
flash('Please choose a status to set.', 'warning')
return redirect(back)
for issue in issues:
if issue.status == new_status:
skipped += 1
continue
old = issue.status
issue.status = new_status
# Keep resolved_at consistent with the status, the same way the
# single-issue update does — a resolved issue with no resolved_at
# breaks the SLA compliance report and the aging buckets.
if new_status == 'resolved' and not issue.resolved_at:
issue.resolved_at = now_eastern()
elif new_status in ('open', 'in_progress'):
issue.resolved_at = None
changed += 1
log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}',
f'bulk status {old}{new_status} by {current_user.username}')
db.session.commit()
_flash_bulk(changed, skipped,
f'set to {new_status.replace("_", " ").title()}')
# ── Verify & close ───────────────────────────────────────────────────
elif action == 'verify':
for issue in issues:
if issue.status not in ('resolved', 'pending_verification'):
skipped += 1
continue
issue.status = 'resolved'
issue.verified_by = current_user.id
issue.verified_at = now_eastern()
if not issue.resolved_at:
issue.resolved_at = now_eastern()
changed += 1
log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}',
f'bulk_verified_by={current_user.username}')
db.session.commit()
_flash_bulk(changed, skipped, 'verified and closed',
skip_reason='not awaiting verification')
# ── Delete ───────────────────────────────────────────────────────────
elif action == 'delete':
from app.utils import storage
photo_paths = []
for issue in issues:
if issue.photo_path:
photo_paths.append(issue.photo_path)
for lst in (issue.mobile_photo_paths, issue.result_photos):
if lst:
photo_paths.extend(lst)
log_action(ACTION_DELETE, 'Issue', issue.id, f'#{issue.id}',
f'bulk deleted by {current_user.username}')
db.session.delete(issue)
changed += 1
db.session.commit()
# Files go only after the rows are safely gone — a failure here leaves
# an orphaned file, which is recoverable; the reverse is not.
for rel_path in photo_paths:
storage.delete(rel_path)
_flash_bulk(changed, skipped, 'permanently deleted')
logger.info('ISSUES | bulk | action=%s user=%s selected=%s changed=%s skipped=%s',
action, current_user.username, len(ids), changed, skipped)
return redirect(back)
def _flash_bulk(changed, skipped, verb, skip_reason='no change needed'):
"""One consistent result message for every bulk action."""
if not changed and not skipped:
flash('Nothing to do.', 'info')
return
parts = [f'{changed} issue{"s" if changed != 1 else ""} {verb}']
if skipped:
parts.append(f'{skipped} skipped ({skip_reason})')
flash('. '.join(parts) + '.', 'success' if changed else 'warning')
@bp.route('/<int:issue_id>/request-verification', methods=['POST']) @bp.route('/<int:issue_id>/request-verification', methods=['POST'])
@@ -974,11 +1162,11 @@ def request_verification(issue_id):
) )
if not can_act: if not can_act:
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('issues.view', issue_id=issue_id)) return redirect(_view_url(issue_id))
if issue.status not in ('in_progress',): if issue.status not in ('in_progress',):
flash('Issue must be in progress to request verification.', 'warning') flash('Issue must be in progress to request verification.', 'warning')
return redirect(url_for('issues.view', issue_id=issue_id)) return redirect(_view_url(issue_id))
issue.status = 'pending_verification' issue.status = 'pending_verification'
db.session.commit() db.session.commit()
@@ -1006,7 +1194,7 @@ def request_verification(issue_id):
) )
db.session.commit() db.session.commit()
flash('Issue marked as pending verification. Supervisors have been notified.', 'info') flash('Issue marked as pending verification. Supervisors have been notified.', 'info')
return redirect(url_for('issues.view', issue_id=issue_id)) return redirect(_view_url(issue_id))
# ── Verification queue ──────────────────────────────────────────────────────── # ── Verification queue ────────────────────────────────────────────────────────
@@ -1099,7 +1287,7 @@ def delete(issue_id):
f'facility={facility_name}; description={issue_desc}') f'facility={facility_name}; description={issue_desc}')
flash(f'Issue #{issue_id_snap} has been permanently deleted.', 'success') flash(f'Issue #{issue_id_snap} has been permanently deleted.', 'success')
return redirect(url_for('issues.index')) return redirect(return_url(url_for('issues.index')))
# ── Quick-assign (AJAX) ─────────────────────────────────────────────────────── # ── Quick-assign (AJAX) ───────────────────────────────────────────────────────
+14 -2
View File
@@ -107,10 +107,15 @@
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body p-0"> <div class="card-body p-0">
{% if inspections.items %} {% if inspections.items %}
{% include 'partials/bulk_inspections_toolbar.html' %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th style="width:34px;">
<input type="checkbox" class="form-check-input bulk-check-all"
title="Select all on this page" aria-label="Select all">
</th>
<th>#</th><th>Date</th><th>Contract</th><th>Facility</th><th>Area</th> <th>#</th><th>Date</th><th>Contract</th><th>Facility</th><th>Area</th>
<th>Template</th><th>Inspector</th><th>Score</th> <th>Template</th><th>Inspector</th><th>Score</th>
<th>Status</th><th></th> <th>Status</th><th></th>
@@ -119,6 +124,11 @@
<tbody> <tbody>
{% for ins in inspections.items %} {% for ins in inspections.items %}
<tr> <tr>
<td>
<input type="checkbox" class="form-check-input bulk-check"
form="inspectionsBulkForm" name="inspection_ids" value="{{ ins.id }}"
aria-label="Select inspection #{{ ins.id }}">
</td>
<td><small class="text-muted">#{{ ins.id }}</small></td> <td><small class="text-muted">#{{ ins.id }}</small></td>
<td>{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}</td> <td>{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}</td>
<td><small>{{ ins.facility.project.name if ins.facility and ins.facility.project else '—' }}</small></td> <td><small>{{ ins.facility.project.name if ins.facility and ins.facility.project else '—' }}</small></td>
@@ -160,9 +170,9 @@
</td> </td>
<td class="text-nowrap"> <td class="text-nowrap">
{% if ins.status == 'in_progress' or ins.status == 'flagged' %} {% if ins.status == 'in_progress' or ins.status == 'flagged' %}
<a href="{{ url_for('inspections.execute', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-primary insp-list-link">Continue</a> <a href="{{ url_for('inspections.execute', inspection_id=ins.id, next=current_url()) }}" class="btn btn-sm btn-outline-primary insp-list-link">Continue</a>
{% else %} {% else %}
<a href="{{ url_for('inspections.view', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-secondary insp-list-link">View</a> <a href="{{ url_for('inspections.view', inspection_id=ins.id, next=current_url()) }}" class="btn btn-sm btn-outline-secondary insp-list-link">View</a>
{% endif %} {% endif %}
{% if current_user.role in ['admin', 'director'] %} {% if current_user.role in ['admin', 'director'] %}
<button type="button" <button type="button"
@@ -224,6 +234,7 @@
</button> </button>
<form id="deleteInspectionForm" method="POST" action="" class="d-inline"> <form id="deleteInspectionForm" method="POST" action="" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ current_url() }}">
<button type="submit" class="btn btn-danger"> <button type="submit" class="btn btn-danger">
<i class="bi bi-trash3-fill"></i> Delete Permanently <i class="bi bi-trash3-fill"></i> Delete Permanently
</button> </button>
@@ -236,6 +247,7 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
{% include 'partials/bulk_select_js.html' %}
<script> <script>
(function () { (function () {
'use strict'; 'use strict';
+13 -2
View File
@@ -338,7 +338,11 @@
{# Action bar #} {# Action bar #}
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<a id="backToInspectionsBtn" href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-secondary"> {# `next` carries the filtered list URL from the list page; the
sessionStorage fallback below still covers links opened before
this page started sending one. #}
{% set back_url = request.args.get('next') or url_for('inspections.index') %}
<a id="backToInspectionsBtn" href="{{ back_url }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Inspections <i class="bi bi-arrow-left"></i> Back to Inspections
</a> </a>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
@@ -378,6 +382,7 @@
action="{{ url_for('inspections.clear_followup', inspection_id=inspection.id) }}" action="{{ url_for('inspections.clear_followup', inspection_id=inspection.id) }}"
class="d-inline"> class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ back_url }}">
<button class="btn btn-sm btn-warning"> <button class="btn btn-sm btn-warning">
<i class="bi bi-flag-fill"></i> Clear Follow-up <i class="bi bi-flag-fill"></i> Clear Follow-up
</button> </button>
@@ -386,6 +391,7 @@
<form method="post" action="{{ url_for('inspections.delete', inspection_id=inspection.id) }}" <form method="post" action="{{ url_for('inspections.delete', inspection_id=inspection.id) }}"
onsubmit="return confirm('Delete this inspection permanently?')"> onsubmit="return confirm('Delete this inspection permanently?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ back_url }}">
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash3"></i> Delete</button> <button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash3"></i> Delete</button>
</form> </form>
{% endif %} {% endif %}
@@ -889,7 +895,11 @@
{% block extra_js %} {% block extra_js %}
<script> <script>
(function () { (function () {
var backUrl = sessionStorage.getItem('insp_list_back_url'); // A server-provided `next` is authoritative — it reflects the list this
// page was actually opened from. Only fall back to sessionStorage when
// there is none (e.g. a link created before `next` was threaded in).
var hasNext = {{ 'true' if request.args.get('next') else 'false' }};
var backUrl = hasNext ? null : sessionStorage.getItem('insp_list_back_url');
if (backUrl) { if (backUrl) {
var btn = document.getElementById('backToInspectionsBtn'); var btn = document.getElementById('backToInspectionsBtn');
if (btn) btn.href = backUrl; if (btn) btn.href = backUrl;
@@ -915,6 +925,7 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMedia()
<div class="modal-dialog"> <div class="modal-dialog">
<form method="POST" action="{{ url_for('inspections.flag_followup', inspection_id=inspection.id) }}"> <form method="POST" action="{{ url_for('inspections.flag_followup', inspection_id=inspection.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ back_url }}">
<div class="modal-content"> <div class="modal-content">
{% set is_cust = current_user.role == 'customer' %} {% set is_cust = current_user.role == 'customer' %}
<div class="modal-header"> <div class="modal-header">
+14 -2
View File
@@ -107,10 +107,15 @@
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body p-0"> <div class="card-body p-0">
{% if issues.items %} {% if issues.items %}
{% include 'partials/bulk_issues_toolbar.html' %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th style="width:34px;">
<input type="checkbox" class="form-check-input bulk-check-all"
title="Select all on this page" aria-label="Select all">
</th>
<th>#</th> <th>#</th>
<th>Reported</th> <th>Reported</th>
<th>Severity</th> <th>Severity</th>
@@ -129,6 +134,11 @@
{% set is_following = issue.id in followed_ids %} {% set is_following = issue.id in followed_ids %}
{% set sla = sla_status(issue) %} {% set sla = sla_status(issue) %}
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}"> <tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
<td>
<input type="checkbox" class="form-check-input bulk-check"
form="issuesBulkForm" name="issue_ids" value="{{ issue.id }}"
aria-label="Select issue #{{ issue.id }}">
</td>
<td><small class="text-muted">#{{ issue.id }}</small></td> <td><small class="text-muted">#{{ issue.id }}</small></td>
<td><small>{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</small></td> <td><small>{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</small></td>
<td> <td>
@@ -203,7 +213,7 @@
class="d-inline" class="d-inline"
title="Unfollow this issue"> title="Unfollow this issue">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ url_for('issues.index', page=issues.page, issue_id=issue_id_filter, severity=severity_filter, status=status_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, reporter_id=reporter_filter, handler_type=handler_filter, unassigned=unassigned_filter) }}"> <input type="hidden" name="next" value="{{ current_url() }}">
<button type="submit" class="btn btn-sm btn-outline-primary p-0 px-1 me-1" <button type="submit" class="btn btn-sm btn-outline-primary p-0 px-1 me-1"
title="Unfollow"> title="Unfollow">
<i class="bi bi-bell-slash" style="font-size:.75rem;"></i> <i class="bi bi-bell-slash" style="font-size:.75rem;"></i>
@@ -211,7 +221,7 @@
</form> </form>
{% endif %} {% endif %}
<a href="{{ url_for('issues.view', issue_id=issue.id) }}" <a href="{{ url_for('issues.view', issue_id=issue.id, next=current_url()) }}"
class="btn btn-sm btn-outline-secondary"> class="btn btn-sm btn-outline-secondary">
{% if current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %} {% if current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %}
<i class="bi bi-pencil"></i> Edit <i class="bi bi-pencil"></i> Edit
@@ -224,6 +234,7 @@
class="d-inline" class="d-inline"
onsubmit="return confirm('Permanently delete Issue #{{ issue.id }}? This cannot be undone.');"> onsubmit="return confirm('Permanently delete Issue #{{ issue.id }}? This cannot be undone.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ current_url() }}">
<button type="submit" class="btn btn-sm btn-outline-danger" <button type="submit" class="btn btn-sm btn-outline-danger"
title="Delete Issue #{{ issue.id }}"> title="Delete Issue #{{ issue.id }}">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
@@ -260,6 +271,7 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
{% include 'partials/bulk_select_js.html' %}
<script> <script>
(function () { (function () {
'use strict'; 'use strict';
+10 -1
View File
@@ -19,6 +19,10 @@
{% block content %} {% block content %}
{% set can_edit = current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %} {% set can_edit = current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %}
{# The filtered list URL this page was opened from (phase: filter
preservation). Threaded into every action so an update or delete
returns to the same filtered page, and used by the Back button. #}
{% set back_url = request.args.get('next') or url_for('issues.index') %}
<div class="row"> <div class="row">
{# ══════════════════════════════════ LEFT COLUMN ══════════════════════════════════ #} {# ══════════════════════════════════ LEFT COLUMN ══════════════════════════════════ #}
@@ -192,6 +196,7 @@
<strong>Awaiting director verification.</strong> <strong>Awaiting director verification.</strong>
{% if current_user.role in ['admin','director','auditor'] %} {% if current_user.role in ['admin','director','auditor'] %}
<form method="POST" action="{{ url_for('issues.verify', issue_id=issue.id) }}" class="mt-2"> <form method="POST" action="{{ url_for('issues.verify', issue_id=issue.id) }}" class="mt-2">
<input type="hidden" name="next" value="{{ back_url }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-2"> <div class="mb-2">
<input type="text" name="verification_note" class="form-control form-control-sm" <input type="text" name="verification_note" class="form-control form-control-sm"
@@ -282,6 +287,7 @@
<div class="card-body"> <div class="card-body">
<p class="fw-semibold small mb-2">Add Comment</p> <p class="fw-semibold small mb-2">Add Comment</p>
<form method="post"> <form method="post">
<input type="hidden" name="next" value="{{ back_url }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="status" value="{{ issue.status }}"> <input type="hidden" name="status" value="{{ issue.status }}">
<input type="hidden" name="assigned_to" value="{{ issue.assigned_to or 0 }}"> <input type="hidden" name="assigned_to" value="{{ issue.assigned_to or 0 }}">
@@ -321,6 +327,7 @@
<div class="card-body"> <div class="card-body">
<p class="fw-semibold small mb-2">Add Comment</p> <p class="fw-semibold small mb-2">Add Comment</p>
<form method="post"> <form method="post">
<input type="hidden" name="next" value="{{ back_url }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-2"> <div class="mb-2">
<textarea name="update_notes" class="form-control" rows="3" <textarea name="update_notes" class="form-control" rows="3"
@@ -395,6 +402,7 @@
<div class="card-header bg-light"><h6 class="mb-0">Update Issue</h6></div> <div class="card-header bg-light"><h6 class="mb-0">Update Issue</h6></div>
<div class="card-body"> <div class="card-body">
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
<input type="hidden" name="next" value="{{ back_url }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3"> <div class="mb-3">
{{ form.status.label(class="form-label fw-semibold") }} {{ form.status.label(class="form-label fw-semibold") }}
@@ -530,7 +538,7 @@
</div> </div>
<div class="d-flex align-items-center gap-2 mt-2"> <div class="d-flex align-items-center gap-2 mt-2">
<a href="{{ url_for('issues.index') }}" class="btn btn-outline-secondary btn-sm"> <a href="{{ back_url }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Back to Issues <i class="bi bi-arrow-left"></i> Back to Issues
</a> </a>
<a href="{{ url_for('issues.export_pdf', issue_id=issue.id) }}" class="btn btn-outline-primary btn-sm"> <a href="{{ url_for('issues.export_pdf', issue_id=issue.id) }}" class="btn btn-outline-primary btn-sm">
@@ -568,6 +576,7 @@
<i class="bi bi-x-circle"></i> Cancel <i class="bi bi-x-circle"></i> Cancel
</button> </button>
<form method="POST" action="{{ url_for('issues.delete', issue_id=issue.id) }}" class="d-inline"> <form method="POST" action="{{ url_for('issues.delete', issue_id=issue.id) }}" class="d-inline">
<input type="hidden" name="next" value="{{ back_url }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-danger"> <button type="submit" class="btn btn-danger">
<i class="bi bi-trash-fill"></i> Delete Permanently <i class="bi bi-trash-fill"></i> Delete Permanently
+14 -2
View File
@@ -162,10 +162,15 @@
{% endif %} {% endif %}
{% if inspections.items %} {% if inspections.items %}
{% include 'partials/bulk_inspections_toolbar.html' %}
<div class="jqc-table-wrap table-responsive"> <div class="jqc-table-wrap table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead> <thead>
<tr> <tr>
<th style="width:34px;">
<input type="checkbox" class="form-check-input bulk-check-all"
title="Select all on this page" aria-label="Select all">
</th>
<th>#</th><th>Date</th><th>Contract</th><th>Facility</th><th>Area</th> <th>#</th><th>Date</th><th>Contract</th><th>Facility</th><th>Area</th>
<th>Template</th><th>Inspector</th><th>Score</th> <th>Template</th><th>Inspector</th><th>Score</th>
<th>Status</th><th></th> <th>Status</th><th></th>
@@ -174,6 +179,11 @@
<tbody> <tbody>
{% for ins in inspections.items %} {% for ins in inspections.items %}
<tr> <tr>
<td>
<input type="checkbox" class="form-check-input bulk-check"
form="inspectionsBulkForm" name="inspection_ids" value="{{ ins.id }}"
aria-label="Select inspection #{{ ins.id }}">
</td>
<td><small class="text-muted">#{{ ins.id }}</small></td> <td><small class="text-muted">#{{ ins.id }}</small></td>
<td class="text-nowrap">{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}</td> <td class="text-nowrap">{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}</td>
<td><small>{{ ins.facility.project.name if ins.facility and ins.facility.project else '—' }}</small></td> <td><small>{{ ins.facility.project.name if ins.facility and ins.facility.project else '—' }}</small></td>
@@ -215,9 +225,9 @@
</td> </td>
<td class="text-nowrap"> <td class="text-nowrap">
{% if ins.status == 'in_progress' or ins.status == 'flagged' %} {% if ins.status == 'in_progress' or ins.status == 'flagged' %}
<a href="{{ url_for('inspections.execute', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-primary insp-list-link">Continue</a> <a href="{{ url_for('inspections.execute', inspection_id=ins.id, next=current_url()) }}" class="btn btn-sm btn-outline-primary insp-list-link">Continue</a>
{% else %} {% else %}
<a href="{{ url_for('inspections.view', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-secondary insp-list-link">View</a> <a href="{{ url_for('inspections.view', inspection_id=ins.id, next=current_url()) }}" class="btn btn-sm btn-outline-secondary insp-list-link">View</a>
{% endif %} {% endif %}
{% if current_user.role in ['admin', 'director'] %} {% if current_user.role in ['admin', 'director'] %}
<button type="button" <button type="button"
@@ -287,6 +297,7 @@
</button> </button>
<form id="deleteInspectionForm" method="POST" action="" class="d-inline"> <form id="deleteInspectionForm" method="POST" action="" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ current_url() }}">
<button type="submit" class="btn btn-danger"> <button type="submit" class="btn btn-danger">
<i class="bi bi-trash3-fill"></i> Delete Permanently <i class="bi bi-trash3-fill"></i> Delete Permanently
</button> </button>
@@ -299,6 +310,7 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
{% include 'partials/bulk_select_js.html' %}
<script> <script>
(function () { (function () {
'use strict'; 'use strict';
+14 -2
View File
@@ -166,10 +166,15 @@
{% endif %} {% endif %}
{% if issues.items %} {% if issues.items %}
{% include 'partials/bulk_issues_toolbar.html' %}
<div class="jqc-table-wrap table-responsive"> <div class="jqc-table-wrap table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead> <thead>
<tr> <tr>
<th style="width:34px;">
<input type="checkbox" class="form-check-input bulk-check-all"
title="Select all on this page" aria-label="Select all">
</th>
<th>#</th> <th>#</th>
<th>Reported</th> <th>Reported</th>
<th>Severity</th> <th>Severity</th>
@@ -188,6 +193,11 @@
{% set is_following = issue.id in followed_ids %} {% set is_following = issue.id in followed_ids %}
{% set sla = sla_status(issue) %} {% set sla = sla_status(issue) %}
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}"> <tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
<td>
<input type="checkbox" class="form-check-input bulk-check"
form="issuesBulkForm" name="issue_ids" value="{{ issue.id }}"
aria-label="Select issue #{{ issue.id }}">
</td>
<td><small class="text-muted">#{{ issue.id }}</small></td> <td><small class="text-muted">#{{ issue.id }}</small></td>
<td class="text-nowrap"><small>{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</small></td> <td class="text-nowrap"><small>{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</small></td>
<td> <td>
@@ -262,7 +272,7 @@
class="d-inline" class="d-inline"
title="Unfollow this issue"> title="Unfollow this issue">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ url_for('issues.index', page=issues.page, issue_id=issue_id_filter, severity=severity_filter, status=status_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, reporter_id=reporter_filter, handler_type=handler_filter, unassigned=unassigned_filter) }}"> <input type="hidden" name="next" value="{{ current_url() }}">
<button type="submit" class="btn btn-sm btn-outline-primary p-0 px-1 me-1" <button type="submit" class="btn btn-sm btn-outline-primary p-0 px-1 me-1"
title="Unfollow"> title="Unfollow">
<i class="bi bi-bell-slash" style="font-size:.75rem;"></i> <i class="bi bi-bell-slash" style="font-size:.75rem;"></i>
@@ -270,7 +280,7 @@
</form> </form>
{% endif %} {% endif %}
<a href="{{ url_for('issues.view', issue_id=issue.id) }}" <a href="{{ url_for('issues.view', issue_id=issue.id, next=current_url()) }}"
class="btn btn-sm btn-outline-secondary"> class="btn btn-sm btn-outline-secondary">
{% if current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %} {% if current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %}
<i class="bi bi-pencil"></i> Edit <i class="bi bi-pencil"></i> Edit
@@ -283,6 +293,7 @@
class="d-inline" class="d-inline"
onsubmit="return confirm('Permanently delete Issue #{{ issue.id }}? This cannot be undone.');"> onsubmit="return confirm('Permanently delete Issue #{{ issue.id }}? This cannot be undone.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ current_url() }}">
<button type="submit" class="btn btn-sm btn-outline-danger" <button type="submit" class="btn btn-sm btn-outline-danger"
title="Delete Issue #{{ issue.id }}"> title="Delete Issue #{{ issue.id }}">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
@@ -324,6 +335,7 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
{% include 'partials/bulk_select_js.html' %}
<script> <script>
(function () { (function () {
'use strict'; 'use strict';
@@ -0,0 +1,57 @@
{# ── Bulk-action toolbar for the inspections list ─────────────────────────────
Included by BOTH inspections/list.html and modern/inspections/list.html —
edit here, not in either copy.
Same structure as the issues toolbar: the form sits OUTSIDE the table and
row checkboxes join it via the HTML5 `form` attribute, so the per-row
delete form inside the table is never nested (rule 9).
Export is offered to anyone who can see the list — it is read-only and the
route re-applies the viewer's facility scope to the submitted ids. The three
mutating actions are admin/director only.
#}
{% set can_manage = current_user.role in ['admin', 'director'] %}
<form method="POST" id="inspectionsBulkForm"
action="{{ url_for('inspections.bulk_action') }}"
class="border-bottom bg-light px-3 py-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ current_url() }}">
<div class="d-flex flex-wrap align-items-center gap-2">
<span class="small fw-semibold text-nowrap">
<span class="bulk-count">0</span> selected
</span>
<span class="text-muted small d-none d-md-inline">|</span>
<button type="submit" name="action" value="export"
class="btn btn-sm btn-outline-secondary text-nowrap" data-bulk-action>
<i class="bi bi-file-earmark-pdf"></i> Export Selected
</button>
{% if can_manage %}
<div class="d-flex align-items-center gap-1">
<input type="text" name="follow_up_note" class="form-control form-control-sm"
style="min-width:180px;font-size:.8rem;"
placeholder="Follow-up note (optional)"
aria-label="Follow-up note applied to all selected">
<button type="submit" name="action" value="flag_followup"
class="btn btn-sm btn-outline-warning text-nowrap" data-bulk-action
data-bulk-confirm="Request a follow-up on the selected inspections? Ones not yet submitted, or already flagged, are skipped.">
<i class="bi bi-flag"></i> Request Follow-up
</button>
</div>
<button type="submit" name="action" value="clear_followup"
class="btn btn-sm btn-outline-success text-nowrap" data-bulk-action
data-bulk-confirm="Clear the follow-up flag on the selected inspections?">
<i class="bi bi-flag-fill"></i> Clear Follow-up
</button>
<button type="submit" name="action" value="delete"
class="btn btn-sm btn-outline-danger text-nowrap ms-auto" data-bulk-action
data-bulk-confirm="Permanently delete the selected inspections and their photos? This cannot be undone.">
<i class="bi bi-trash"></i> Delete
</button>
{% endif %}
</div>
</form>
@@ -0,0 +1,79 @@
{# ── Bulk-action toolbar for the issues list ──────────────────────────────────
Included by BOTH issues/list.html and modern/issues/list.html — edit here,
not in either copy.
The form lives OUTSIDE the table on purpose. Row checkboxes join it with the
HTML5 `form="issuesBulkForm"` attribute instead of being wrapped by it, so
the per-row delete / unfollow forms inside the table are never nested inside
this one (rule 9 — browsers silently discard nested forms, and the row
actions would stop working with no error).
`next` carries the current filtered list URL so the action returns here
rather than to the bare index.
Requires from the view: `staff` (assignable users).
#}
{% set can_manage = current_user.role in ['admin', 'director', 'auditor'] %}
{% set can_delete = current_user.role in ['admin', 'director'] %}
{% if can_manage or can_delete %}
<form method="POST" id="issuesBulkForm"
action="{{ url_for('issues.bulk_action') }}"
class="border-bottom bg-light px-3 py-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ current_url() }}">
<div class="d-flex flex-wrap align-items-center gap-2">
<span class="small fw-semibold text-nowrap">
<span class="bulk-count">0</span> selected
</span>
<span class="text-muted small d-none d-md-inline">|</span>
{% if can_manage %}
<div class="d-flex align-items-center gap-1">
<select name="assigned_to" class="form-select form-select-sm"
style="min-width:150px;font-size:.8rem;" aria-label="Assign selected to">
<option value="0">— Unassigned —</option>
{% for u in staff %}
<option value="{{ u.id }}">{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}</option>
{% endfor %}
</select>
<button type="submit" name="action" value="assign"
class="btn btn-sm btn-outline-primary text-nowrap" data-bulk-action
data-bulk-confirm="Assign the selected issues to the chosen user?">
<i class="bi bi-person-check"></i> Assign
</button>
</div>
<div class="d-flex align-items-center gap-1">
<select name="status" class="form-select form-select-sm"
style="min-width:150px;font-size:.8rem;" aria-label="Set status of selected">
<option value="">— Set status… —</option>
<option value="open">Open</option>
<option value="in_progress">In Progress</option>
<option value="pending_verification">Pending Verification</option>
<option value="resolved">Resolved</option>
</select>
<button type="submit" name="action" value="status"
class="btn btn-sm btn-outline-primary text-nowrap" data-bulk-action
data-bulk-confirm="Change the status of the selected issues?">
<i class="bi bi-arrow-repeat"></i> Apply
</button>
</div>
<button type="submit" name="action" value="verify"
class="btn btn-sm btn-outline-success text-nowrap" data-bulk-action
data-bulk-confirm="Verify and close the selected issues? Issues that are not awaiting verification are skipped.">
<i class="bi bi-patch-check"></i> Verify &amp; Close
</button>
{% endif %}
{% if can_delete %}
<button type="submit" name="action" value="delete"
class="btn btn-sm btn-outline-danger text-nowrap ms-auto" data-bulk-action
data-bulk-confirm="Permanently delete the selected issues and their photos? This cannot be undone.">
<i class="bi bi-trash"></i> Delete
</button>
{% endif %}
</div>
</form>
{% endif %}
@@ -0,0 +1,77 @@
{# ── Shared row-selection behaviour for bulk-action list pages ────────────────
Included by the issues and inspections list templates (classic + modern).
Generic on purpose — it keys off classes/attributes, not page-specific ids,
so both pages share one implementation:
.bulk-check one per row (name=issue_ids / inspection_ids)
.bulk-check-all the header select-all box
.bulk-count element whose text becomes the selected count
[data-bulk-action] submit buttons, disabled while nothing is selected
[data-bulk-confirm] optional confirm text, count substituted for {n}
Guarding the submit on a zero selection matters: the browser would happily
POST an empty id list, and the route would flash "No issues selected" after
a full page round trip.
#}
<script>
(function () {
'use strict';
var boxes = Array.prototype.slice.call(document.querySelectorAll('.bulk-check'));
var all = document.querySelector('.bulk-check-all');
var counts = Array.prototype.slice.call(document.querySelectorAll('.bulk-count'));
var btns = Array.prototype.slice.call(document.querySelectorAll('[data-bulk-action]'));
if (!boxes.length) return;
function selected() {
return boxes.filter(function (b) { return b.checked; });
}
function sync() {
var n = selected().length;
counts.forEach(function (el) { el.textContent = n; });
btns.forEach(function (b) { b.disabled = (n === 0); });
if (all) {
all.checked = (n > 0 && n === boxes.length);
// Distinguishes "some" from "none"/"all" in the header box.
all.indeterminate = (n > 0 && n < boxes.length);
}
}
boxes.forEach(function (b) { b.addEventListener('change', sync); });
if (all) {
all.addEventListener('change', function () {
boxes.forEach(function (b) { b.checked = all.checked; });
sync();
});
}
// Shift-click selects the range from the last clicked box — the usual
// convention, and the difference between ticking 3 boxes and 40.
var lastIndex = null;
boxes.forEach(function (b, i) {
b.addEventListener('click', function (e) {
if (e.shiftKey && lastIndex !== null) {
var lo = Math.min(lastIndex, i), hi = Math.max(lastIndex, i);
for (var j = lo; j <= hi; j++) { boxes[j].checked = b.checked; }
sync();
}
lastIndex = i;
});
});
btns.forEach(function (btn) {
btn.addEventListener('click', function (e) {
var n = selected().length;
if (n === 0) { e.preventDefault(); return; }
var msg = btn.getAttribute('data-bulk-confirm');
if (msg && !window.confirm(msg.replace('{n}', n) + '\n\n' + n + ' selected.')) {
e.preventDefault();
}
});
});
sync();
}());
</script>
+23
View File
@@ -28,6 +28,29 @@ def safe_redirect_url(url: str | None, fallback: str | None = None) -> str:
return fallback return fallback
return url return url
def return_url(fallback: str) -> str:
"""Where to go back to after a list-page action, preserving its filters.
Reads the `next` value the page carried through the action POST body
first (forms), then query string (links) and validates it with
safe_redirect_url, so a crafted `next` can never redirect off-site.
The problem this solves: a delete or an edit launched from a filtered list
used to redirect to the bare index, throwing away the filters the user had
set. Every list-page action now round-trips the list URL instead.
`next` is deliberately the FULL list URL (page number and all), not a
reconstructed set of arguments that keeps this helper working when a new
filter is added to either list page without anyone having to remember to
thread it through here.
"""
from flask import request
return safe_redirect_url(
request.form.get('next') or request.args.get('next'),
fallback=fallback,
)
def admin_required(f): def admin_required(f):
@wraps(f) @wraps(f)
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):