Aug 17 - Update bulk actions for inspection/issue list
This commit is contained in:
@@ -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` (0–100 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` (0–100 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. |
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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) ───────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 & 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>
|
||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user