Compare commits
10
Commits
df547eefc2
...
08b9f088a8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08b9f088a8 | ||
|
|
4900ddc2cc | ||
|
|
56bcf9ed63 | ||
|
|
66dbf31c34 | ||
|
|
5a42b21a04 | ||
|
|
faf4796b4a | ||
|
|
593c630e1b | ||
|
|
c64df7ca34 | ||
|
|
f9d00b7c19 | ||
|
|
fcb959900f |
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **Audience:** AI assistants and developers working on this codebase.
|
> **Audience:** AI assistants and developers working on this codebase.
|
||||||
> **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions.
|
> **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions.
|
||||||
> **Last reviewed:** July 2026 (Phase 19 complete + mobile API gap-fill Phases A–E + customer UI refinements + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector performance Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + date/ID filters + Reports expansion Phases R1–R4 + Phase 24 issue_created notify defaults + Phase 25 inspection GPS + Phase 26 issue vendor fields + Phase 27 facility score alerts + Phase 28 inspection-notify fix + Phase 29 admin broadcasts + Phases 30–32 device registry consolidation + ProxyFix reverse-proxy fix + Phase 33 per-contract notification recipients + grouped Admin nav dropdown + forgot-password case-insensitive lookup & email normalization + transactional email sender/branding fix + Phase 34 facility QR public pages & report-a-problem + Phase 35 issue handler_type (our staff / facility / vendor) + Phase 36 scheduled inspections + Phase 37 support chat persistence + Phase 38 support knowledge base + Phase 39 per-area QR public pages)
|
> **Last reviewed:** July 2026 (Phase 19 complete + mobile API gap-fill Phases A–E + customer UI refinements + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector performance Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + date/ID filters + Reports expansion Phases R1–R4 + Phase 24 issue_created notify defaults + Phase 25 inspection GPS + Phase 26 issue vendor fields + Phase 27 facility score alerts + Phase 28 inspection-notify fix + Phase 29 admin broadcasts + Phases 30–32 device registry consolidation + ProxyFix reverse-proxy fix + Phase 33 per-contract notification recipients + grouped Admin nav dropdown + forgot-password case-insensitive lookup & email normalization + transactional email sender/branding fix + Phase 34 facility QR public pages & report-a-problem + Phase 35 issue handler_type (our staff / facility / vendor) + Phase 36 scheduled inspections + Phase 37 support chat persistence + Phase 38 support knowledge base + Phase 39 per-area QR public pages + Phase 40 auditor role + Phase 41 issue internal handler name + Phase 42 internal handler contact)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -191,7 +191,9 @@ users: id, username (unique, indexed), full_name, email (unique, indexed),
|
|||||||
password_set, set_password_token (indexed), set_password_token_expires
|
password_set, set_password_token (indexed), set_password_token_expires
|
||||||
```
|
```
|
||||||
|
|
||||||
**Role ENUM:** `admin`, `director`, `inspector`, `project_manager`, `customer`
|
**Role ENUM:** `admin`, `director`, `inspector`, `project_manager`, `customer`, `auditor`
|
||||||
|
|
||||||
|
**`auditor` (Phase 40):** A staff role with the **same access as `project_manager`** (it is included in `@project_manager_required` and everywhere `project_manager` is checked) **plus full issue-management powers** — create, assign, quick-assign, handler/vendor triage, request-verification, and verify/bulk-verify/verification-queue (via the new `@issue_manager_required` decorator). **Auditor does NOT get issue deletion** (that stays admin/director via `@supervisor_required`), nor any other admin/director-only area PM lacks (users, audit trail, notification matrix, customers, templates). Auditors are **assignable** as an issue/inspection assignee; **admin was removed** from the assignable set at the same time (assignee dropdowns are now `director`/`inspector`/`auditor`, plus `project_manager` on the inspection flag-issue dropdown). The issue-update route defensively keeps any pre-existing out-of-set assignee (e.g. a legacy admin assignment) in the dropdown so saving never silently unassigns. Auditor **has mobile-API access** — it is included in the `_ALLOWED_ROLES` set of every `app/api/*` module (comments, inspections, issues, photos, scheduled, stats, templates), so the iPad app accepts auditor logins. In every API endpoint that scopes by role, auditor falls into the non-inspector/non-customer (privileged) branch — org-wide data, same as admin/director/PM.
|
||||||
|
|
||||||
**Key property:** `display_name` → `full_name.strip()` or falls back to `username`.
|
**Key property:** `display_name` → `full_name.strip()` or falls back to `username`.
|
||||||
|
|
||||||
@@ -253,20 +255,24 @@ issues: id, inspection_id (nullable), area_id, facility_id (nullable), severity
|
|||||||
handler_type ENUM('internal','facility','vendor') NOT NULL DEFAULT 'internal', ← Phase 35
|
handler_type ENUM('internal','facility','vendor') NOT NULL DEFAULT 'internal', ← Phase 35
|
||||||
facility_handler_name VARCHAR(100) nullable, ← Phase 35
|
facility_handler_name VARCHAR(100) nullable, ← Phase 35
|
||||||
facility_handler_contact VARCHAR(200) nullable, ← Phase 35
|
facility_handler_contact VARCHAR(200) nullable, ← Phase 35
|
||||||
facility_handler_notes TEXT nullable ← Phase 35
|
facility_handler_notes TEXT nullable, ← Phase 35
|
||||||
|
internal_handler_name VARCHAR(100) nullable, ← Phase 41
|
||||||
|
internal_handler_contact VARCHAR(200) nullable ← Phase 42
|
||||||
```
|
```
|
||||||
|
|
||||||
**Handler (`handler_type`, Phase 35) — who is doing the work:**
|
**Handler (`handler_type`, Phase 35) — who is doing the work:**
|
||||||
|
|
||||||
| Value | Meaning | Detail fields | `assigned_to` role |
|
| Value | Meaning | Detail fields | `assigned_to` role |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `internal` (default) | Janitorial Staff (our crew) | — (the assignee IS the handler) | the handler |
|
| `internal` (default) | Janitorial Staff (our crew) | `internal_handler_name`/`internal_handler_contact` (Phase 41/42, free text — the crew member's name + phone/email, optional) | the handler |
|
||||||
| `facility` | The facility's own staff | `facility_handler_name/contact/notes` (free text) | internal **follow-up owner** |
|
| `facility` | The facility's own staff | `facility_handler_name/contact/notes` (free text) | internal **follow-up owner** |
|
||||||
| `vendor` | External contractor | `vendor_name/contact/notes` (Phase 26) | internal **follow-up owner** |
|
| `vendor` | External contractor | `vendor_name/contact/notes` (Phase 26) | internal **follow-up owner** |
|
||||||
|
|
||||||
**Display labels are perspective-neutral** (they read the same for staff and customers) with a descriptor line under the selector and a tooltip on badges: `internal` → **"Janitorial Staff"** ("Our janitorial crew handles it."), `facility` → **"Facility Staff"** ("The facility's own on-site staff handle it."), `vendor` → **"External Vendor"** ("An outside contractor handles it."). Labels/descriptions live in `Issue.HANDLER_LABELS` / `HANDLER_DESCRIPTIONS`, the WTForms `handler_type` choices, and the `HANDLER_DESC` JS map in both issue templates — keep these in sync. Do **not** use viewer-relative words like "Our"/"Your" for the stored categories.
|
**Display labels are perspective-neutral** (they read the same for staff and customers) with a descriptor line under the selector and a tooltip on badges: `internal` → **"Janitorial Staff"** ("Our janitorial crew handles it."), `facility` → **"Facility Staff"** ("The facility's own on-site staff handle it."), `vendor` → **"External Vendor"** ("An outside contractor handles it."). Labels/descriptions live in `Issue.HANDLER_LABELS` / `HANDLER_DESCRIPTIONS`, the WTForms `handler_type` choices, and the `HANDLER_DESC` JS map in both issue templates — keep these in sync. Do **not** use viewer-relative words like "Our"/"Your" for the stored categories.
|
||||||
|
|
||||||
`assigned_to` (a JQC User) is **always** available: it is the handler for `internal`, and the internal follow-up owner (e.g. the inspector who verifies/updates) for `facility`/`vendor`. Settable in **two places**, both with a "Handled By" selector that reveals the facility or vendor sub-fields via JS:
|
Free-text **`internal_handler_name`** (Phase 41) + **`internal_handler_contact`** (Phase 42, phone/email) capture the janitorial crew member's name and contact when `handler_type == 'internal'` — the actual person doing the work, who may not be a system User. They are distinct from `assigned_to` (the follow-up owner) and are revealed by the same "Handled By" selector JS as the facility/vendor blocks (`#internal_handler_block`). Displayed under a **"Staff"** row (name + contact) on the issue detail when set.
|
||||||
|
|
||||||
|
`assigned_to` (a JQC User) is **always** available: it is the handler for `internal`, and the internal follow-up owner (e.g. the inspector who verifies/updates) for `facility`/`vendor`. Settable in **two places**, both with a "Handled By" selector that reveals the janitorial/facility/vendor sub-fields via JS:
|
||||||
- **Log New Issue** form (`issues/form.html`) — at creation, for non-customer staff. Customer-created issues stay `internal` (the handler UI is hidden for them, same as `assigned_to`).
|
- **Log New Issue** form (`issues/form.html`) — at creation, for non-customer staff. Customer-created issues stay `internal` (the handler UI is hidden for them, same as `assigned_to`).
|
||||||
- **Update Issue** panel on the issue detail page (`issues/view.html`) — triage after creation.
|
- **Update Issue** panel on the issue detail page (`issues/view.html`) — triage after creation.
|
||||||
|
|
||||||
@@ -277,7 +283,7 @@ Triage of `handler_type` + facility/vendor detail fields on the **update** panel
|
|||||||
| Column | Type | Populated by | Displayed as |
|
| Column | Type | Populated by | Displayed as |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `photo_path` | `VARCHAR(255)` | Web form upload OR first iPad photo | "Photo Evidence" (primary) |
|
| `photo_path` | `VARCHAR(255)` | Web form upload OR first iPad photo | "Photo Evidence" (primary) |
|
||||||
| `mobile_photo_paths` | `JSON` (`list[str]`) | iPad PATCH `/issues/<id>/photos` — extra evidence photos | "Photo Evidence" (additional) |
|
| `mobile_photo_paths` | `JSON` (`list[str]`) | iPad PATCH `/issues/<id>/photos` — extra evidence photos; **also public QR "report a problem" (photos 2–5)** | "Photo Evidence" (additional) |
|
||||||
| `result_photos` | `JSON` (`list[str]`) | Web update form file upload — resolution photos | "Resolution Details" |
|
| `result_photos` | `JSON` (`list[str]`) | Web update form file upload — resolution photos | "Resolution Details" |
|
||||||
|
|
||||||
**Rule:** Never write iPad evidence photos into `result_photos`. They belong in `mobile_photo_paths` so they appear under "Photo Evidence" on the web, not "Resolution Details".
|
**Rule:** Never write iPad evidence photos into `result_photos`. They belong in `mobile_photo_paths` so they appear under "Photo Evidence" on the web, not "Resolution Details".
|
||||||
@@ -426,6 +432,8 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi
|
|||||||
|
|
||||||
## 6. Role & Permission Matrix
|
## 6. Role & Permission Matrix
|
||||||
|
|
||||||
|
**`auditor` reads as a `project_manager` column** below, with these overrides: **Issues (quick-assign)** ✅, **Issue verification** ✅, and it appears in the **Issues (create/assign)** and **Issue verification** rows as ✅. It never gains issue *delete* or any admin/director-only row PM lacks. See the `auditor` note in §5.
|
||||||
|
|
||||||
| Area | admin | director | project_manager | inspector | customer |
|
| Area | admin | director | project_manager | inspector | customer |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| Dashboard | ✅ full | ✅ full | ✅ full | ✅ limited | ✅ scoped |
|
| Dashboard | ✅ full | ✅ full | ✅ full | ✅ limited | ✅ scoped |
|
||||||
@@ -454,7 +462,8 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi
|
|||||||
```python
|
```python
|
||||||
@admin_required # role == 'admin' only
|
@admin_required # role == 'admin' only
|
||||||
@supervisor_required # role in ('admin', 'director') — name kept to avoid touching 30+ routes
|
@supervisor_required # role in ('admin', 'director') — name kept to avoid touching 30+ routes
|
||||||
@project_manager_required # role in ('admin', 'director', 'project_manager')
|
@project_manager_required # role in ('admin', 'director', 'project_manager', 'auditor')
|
||||||
|
@issue_manager_required # role in ('admin', 'director', 'auditor') — issue verification (NOT delete)
|
||||||
@customer_required # role == 'customer' only
|
@customer_required # role == 'customer' only
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -466,13 +475,13 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `auth` | `/auth` | `/login`, `/logout`, `/profile`, `/users/*`, `/notification-matrix` |
|
| `auth` | `/auth` | `/login`, `/logout`, `/profile`, `/users/*`, `/notification-matrix` |
|
||||||
| `dashboard` | `/` | `GET /`, `/facility-trend` (AJAX) |
|
| `dashboard` | `/` | `GET /`, `/facility-trend` (AJAX) |
|
||||||
| `facilities` | `/facilities` | CRUD + area management + QR code: `/<id>/qr` printable page, `/<id>/qr.png` image, `POST /<id>/qr/regenerate` (invalidates old printed code), `/qr/print-all[?contract_id=]` bulk sheet. **Per-area QR (Phase 39):** `/areas/<id>/qr`, `/areas/<id>/qr.png`, `POST /areas/<id>/qr/regenerate` — mirror the facility QR routes; scope enforced by `_area_for_qr_or_403()` via the area's parent facility. **Customers may use all QR actions (including regenerate) for their own assigned facilities**; inspectors/PM/admin/director for any. Scope enforced by `_facility_for_qr_or_403()` (customers) / `get_customer_scope` (print-all). Regenerate is limited to admin/director + scoped customer (PM/inspector excluded). |
|
| `facilities` | `/facilities` | CRUD + area management + QR code: `/<id>/qr` printable page, `/<id>/qr.png` image, `POST /<id>/qr/regenerate` (invalidates old printed code), `/qr/print-all[?contract_id=]` bulk sheet. **Per-area QR (Phase 39):** `/areas/<id>/qr`, `/areas/<id>/qr.png`, `POST /areas/<id>/qr/regenerate` — mirror the facility QR routes; scope enforced by `_area_for_qr_or_403()` via the area's parent facility. **Customers may use all QR actions (including regenerate) for their own assigned facilities**; inspectors/PM/admin/director for any. Scope enforced by `_facility_for_qr_or_403()` (customers) / `get_customer_scope` (print-all). Regenerate is limited to admin/director + scoped customer (PM/inspector excluded). **QR print/export page:** `GET /qr/print-all` is a selectable sheet with filters `?contract_id=` / `?facility_id=` / `?include_areas=1` (contract narrows the facility dropdown; areas render each facility's per-area QR cards). Each card is a `<label>` wrapping a checkbox; **Print Selected** (JS toggles `body.print-selected-only` so `@media print` hides unticked cards) and **Export Selected to PDF** (`POST /qr/export-pdf`, repeated `facility_ids`/`area_ids`, scope re-checked per id via the `_*_for_qr_or_403()` helpers, streams `generate_qr_codes_pdf()` output; logs `ACTION_EXPORT`). Inspectors 403. QR PNG bytes for the PDF come from `_qr_png_bytes(url)`. |
|
||||||
| `public` | `/f` | **No login.** `GET /<token>` occupant facility summary + `POST /<token>/report` occupant issue report; `GET /area/<token>` per-area summary + `POST /area/<token>/report` (Phase 39, files with `area_id` set). All report POSTs rate-limited `5/hour`, honeypot-guarded. Resolves ACTIVE facility (area's parent must be active) by `public_token` or 404. |
|
| `public` | `/f` | **No login.** `GET /<token>` occupant facility summary + `POST /<token>/report` occupant issue report; `GET /area/<token>` per-area summary + `POST /area/<token>/report` (Phase 39, files with `area_id` set). Report form accepts **up to 5 photos** (`_save_report_photos()` → `photo_path` + `mobile_photo_paths`). All report POSTs rate-limited `5/hour`, honeypot-guarded. Resolves ACTIVE facility (area's parent must be active) by `public_token` or 404. |
|
||||||
| `projects` | `/projects` | CRUD + customer assignment management + notification-recipient add/remove (`/<id>/notify-recipients/add`, `/notify-recipients/<rid>/remove` — admin only) |
|
| `projects` | `/projects` | CRUD + customer assignment management + notification-recipient add/remove (`/<id>/notify-recipients/add`, `/notify-recipients/<rid>/remove` — admin only) |
|
||||||
| `customers` | `/customers` | list, invite, set-password, manage, import CSV |
|
| `customers` | `/customers` | list, invite, set-password, manage, import CSV |
|
||||||
| `inspections` | `/inspections` | list, start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect, upload-photo (AJAX) |
|
| `inspections` | `/inspections` | list, start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect, upload-photo (AJAX) |
|
||||||
| `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 |
|
| `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).** |
|
||||||
| `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 |
|
||||||
@@ -531,6 +540,7 @@ ReportLab-based. 12-column grid must be preserved — never collapse in PDF view
|
|||||||
- `generate_issues_list_pdf(issues, filter_summary)` — landscape issues list PDF (from issues list export)
|
- `generate_issues_list_pdf(issues, filter_summary)` — landscape issues list PDF (from issues list export)
|
||||||
- `generate_inspections_list_pdf(inspections, filter_summary)` — landscape inspections list PDF
|
- `generate_inspections_list_pdf(inspections, filter_summary)` — landscape inspections list PDF
|
||||||
- `generate_facility_summary_pdf(facility, days, start, now, total_inspections, avg_score, area_scores, open_issues, resolved_count)` — customer-facing one-page facility summary PDF (Phase R4)
|
- `generate_facility_summary_pdf(facility, days, start, now, total_inspections, avg_score, area_scores, open_issues, resolved_count)` — customer-facing one-page facility summary PDF (Phase R4)
|
||||||
|
- `generate_qr_codes_pdf(items, filter_summary='')` — grid of selected facility/area QR codes. `items` = list of `{title, subtitle, caption, png(bytes)}`; 3-per-row portrait sheet. Backs `POST /facilities/qr/export-pdf`.
|
||||||
|
|
||||||
**`_build_styles()` registered style names:** `ReportTitle`, `ReportSub`, `SectionHead`, `FieldLabel`, `FieldValue`, `MetaLabel`, `MetaValue`, `IssueDesc`, `FooterStyle`, `SummaryTitle`, `ReportSubtitle`, `Meta`, `ScoreValue`, `ScoreLabel`, `SectionHeader`, `TableHeader`, `TableCell`
|
**`_build_styles()` registered style names:** `ReportTitle`, `ReportSub`, `SectionHead`, `FieldLabel`, `FieldValue`, `MetaLabel`, `MetaValue`, `IssueDesc`, `FooterStyle`, `SummaryTitle`, `ReportSubtitle`, `Meta`, `ScoreValue`, `ScoreLabel`, `SectionHeader`, `TableHeader`, `TableCell`
|
||||||
|
|
||||||
@@ -640,6 +650,7 @@ No migration was needed for either feature: the `scheduled_inspections` table (p
|
|||||||
'handler_label', # human-readable label (Issue.handler_label property)
|
'handler_label', # human-readable label (Issue.handler_label property)
|
||||||
'facility_handler_name', 'facility_handler_contact', 'facility_handler_notes', # nullable
|
'facility_handler_name', 'facility_handler_contact', 'facility_handler_notes', # nullable
|
||||||
'vendor_name', 'vendor_contact', 'vendor_notes', # nullable
|
'vendor_name', 'vendor_contact', 'vendor_notes', # nullable
|
||||||
|
'internal_handler_name', 'internal_handler_contact', # Phase 41/42 — janitorial staff name + contact (nullable)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -704,6 +715,10 @@ EVENT_SCORE_ALERT = 'score_alert' ← Phase 27
|
|||||||
EVENT_SCHEDULED_INSPECTION = 'scheduled_inspection' ← Phase 36
|
EVENT_SCHEDULED_INSPECTION = 'scheduled_inspection' ← Phase 36
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Inspector role scoping for `inspection_completed`
|
||||||
|
|
||||||
|
`notify_by_matrix()` special-cases the **inspector** role for the `inspection_completed` event: instead of notifying every active inspector, it notifies **only the inspection's own inspector** (`Inspection.inspector_id`, resolved from the passed `inspection_id`). So enabling the "Inspector" column for "Inspection completed" in the matrix alerts just the inspector who submitted that inspection — not the whole inspector pool. All three dispatch sites (web `routes/inspections.py`, both mobile-API `api/inspections.py`) pass `inspection_id`, so the scoping applies uniformly; if `inspection_id` is ever omitted for this event, the inspector role notifies no one (fail-closed). Other roles/events are unaffected.
|
||||||
|
|
||||||
### Per-Contract Additional Recipients (Phase 33)
|
### Per-Contract Additional Recipients (Phase 33)
|
||||||
|
|
||||||
`notify_by_matrix()` is the single dispatch point for all broadcast events. After routing to the global matrix roles + global custom emails, it calls `_notify_contract_recipients()`, which:
|
`notify_by_matrix()` is the single dispatch point for all broadcast events. After routing to the global matrix roles + global custom emails, it calls `_notify_contract_recipients()`, which:
|
||||||
@@ -806,7 +821,10 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif
|
|||||||
→ phase36_scheduled_insp
|
→ phase36_scheduled_insp
|
||||||
→ phase37_support_chat
|
→ phase37_support_chat
|
||||||
→ phase38_support_knowledge
|
→ phase38_support_knowledge
|
||||||
→ phase39_area_public_token ← HEAD
|
→ phase39_area_public_token
|
||||||
|
→ phase40_auditor_role
|
||||||
|
→ phase41_internal_handler
|
||||||
|
→ phase42_internal_contact ← HEAD
|
||||||
```
|
```
|
||||||
|
|
||||||
### phase21_performance_indexes
|
### phase21_performance_indexes
|
||||||
@@ -955,6 +973,36 @@ flask db upgrade # adds + backfills areas.public_token
|
|||||||
sudo systemctl restart gunicorn
|
sudo systemctl restart gunicorn
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### phase41_internal_handler
|
||||||
|
|
||||||
|
Revision id `phase41_internal_handler` (file `phase41_internal_handler_name.py`). Adds `issues.internal_handler_name VARCHAR(100) NULL` — the free-text janitorial staff member's name used when `handler_type == 'internal'` (see §5 Issue + the Handler section). `INFORMATION_SCHEMA` column-existence check — safe to re-run.
|
||||||
|
|
||||||
|
**Deploy order:**
|
||||||
|
```bash
|
||||||
|
flask db upgrade
|
||||||
|
sudo systemctl restart gunicorn
|
||||||
|
```
|
||||||
|
|
||||||
|
### phase42_internal_contact
|
||||||
|
|
||||||
|
Revision id `phase42_internal_contact` (file `phase42_internal_handler_contact.py`). Adds `issues.internal_handler_contact VARCHAR(200) NULL` — phone/email for the janitorial staff member handling the issue when `handler_type == 'internal'`; parallels `internal_handler_name`. `INFORMATION_SCHEMA` column-existence check — safe to re-run.
|
||||||
|
|
||||||
|
**Deploy order:**
|
||||||
|
```bash
|
||||||
|
flask db upgrade
|
||||||
|
sudo systemctl restart gunicorn
|
||||||
|
```
|
||||||
|
|
||||||
|
### phase40_auditor_role
|
||||||
|
|
||||||
|
Revision id `phase40_auditor_role`. Adds the `auditor` value to the `users.role` ENUM (`ALTER TABLE users MODIFY COLUMN role ENUM(...,'auditor') NOT NULL`). This is a **pure ENUM expansion** (adds a value, removes/migrates nothing), so the 3-step ENUM protocol does not apply and the `MODIFY` is idempotent — safe to re-run. `downgrade()` reassigns any `auditor` rows to `project_manager` before contracting the ENUM. Backs the new Auditor role — see the `auditor` note in §5 and the `@issue_manager_required` decorator in §6.
|
||||||
|
|
||||||
|
**Deploy order:**
|
||||||
|
```bash
|
||||||
|
flask db upgrade # expands users.role ENUM with 'auditor'
|
||||||
|
sudo systemctl restart gunicorn
|
||||||
|
```
|
||||||
|
|
||||||
**Deploy order for phases 24–32:**
|
**Deploy order for phases 24–32:**
|
||||||
```bash
|
```bash
|
||||||
flask db upgrade
|
flask db upgrade
|
||||||
@@ -1295,11 +1343,13 @@ timeout = 30
|
|||||||
| 71 | **`ProxyFix` must wrap `app.wsgi_app` in `create_app()`** | Behind Nginx, `remote_addr` is `127.0.0.1` for every request without it, collapsing all Flask-Limiter keys into one bucket (global instead of per-client rate limiting). `x_for=1` trusts exactly one proxy hop. See §19. |
|
| 71 | **`ProxyFix` must wrap `app.wsgi_app` in `create_app()`** | Behind Nginx, `remote_addr` is `127.0.0.1` for every request without it, collapsing all Flask-Limiter keys into one bucket (global instead of per-client rate limiting). `x_for=1` trusts exactly one proxy hop. See §19. |
|
||||||
| 72 | **Device registration has exactly ONE implementation — `register_device()` in `app/api/auth.py` → `api_device_tokens`** | A second `POST /api/v1/devices/register` (`app/api/devices.py` + `DeviceRegistration` model) was removed July 2026. It was shadowed by the `api_auth` route at routing time and queried the dropped `device_registrations` table. Do not reintroduce a competing device model or duplicate register route. |
|
| 72 | **Device registration has exactly ONE implementation — `register_device()` in `app/api/auth.py` → `api_device_tokens`** | A second `POST /api/v1/devices/register` (`app/api/devices.py` + `DeviceRegistration` model) was removed July 2026. It was shadowed by the `api_auth` route at routing time and queried the dropped `device_registrations` table. Do not reintroduce a competing device model or duplicate register route. |
|
||||||
| 73 | **Per-contract recipients are dispatched ONLY inside `notify_by_matrix()` — never add a parallel path** | `_notify_contract_recipients()` runs after role + global-custom-email routing and shares the `notified` / `sent_emails` dedup sets. Any new event that should reach contract recipients must go through `notify_by_matrix()` (passing `facility_id`, or an `issue_id`/`inspection_id` that resolves to one). Bypassing it means contract recipients are silently skipped and dedup breaks. Commit stays the caller's responsibility. |
|
| 73 | **Per-contract recipients are dispatched ONLY inside `notify_by_matrix()` — never add a parallel path** | `_notify_contract_recipients()` runs after role + global-custom-email routing and shares the `notified` / `sent_emails` dedup sets. Any new event that should reach contract recipients must go through `notify_by_matrix()` (passing `facility_id`, or an `issue_id`/`inspection_id` that resolves to one). Bypassing it means contract recipients are silently skipped and dedup breaks. Commit stays the caller's responsibility. |
|
||||||
| 74 | **The `public` blueprint (`/f/*`) is login-free — keep it occupant-safe** | Pages are addressed by unguessable `public_token` (never facility id), 404 on inactive/unknown facilities, and expose only a quality rating, last-inspected date, and open-issue COUNT — **never** issue descriptions, inspector names, per-item scores, or any other facility's data. The `report` POST must stay CSRF-protected (Flask-WTF form), rate-limited, and honeypot-guarded; public-reported issues are created with `reported_by=NULL`, `severity='medium'`, and routed through `notify_by_matrix('issue_created', facility_id=...)`. Do not add fields that leak internal detail, and do not reuse `render_template('base.html')` here — the public page is a standalone template with no authenticated nav. |
|
| 74 | **The `public` blueprint (`/f/*`) is login-free — keep it occupant-safe** | Pages are addressed by unguessable `public_token` (never facility id), 404 on inactive/unknown facilities, and expose only a quality rating, last-inspected date, and open-issue COUNT — **never** issue descriptions, inspector names, per-item scores, or any other facility's data. The `report` POST must stay CSRF-protected (Flask-WTF form), rate-limited, honeypot-guarded, and **idempotency-guarded** (`_recent_duplicate_report()` — an identical public report for the same facility/area within `DUPLICATE_REPORT_WINDOW_SECONDS`=60s is silently accepted as success without creating a second issue or saving its photos; the dedup check runs BEFORE `_save_report_photos()` to avoid orphaned uploads). The client also disables the submit button on first tap. Public-reported issues are created with `reported_by=NULL`, `severity='medium'`, and routed through `notify_by_matrix('issue_created', facility_id=...)`. **Photos:** the report form accepts **up to 5 photos** (`PublicIssueReportForm.photos`, a `MultipleFileField`); `_save_report_photos()` in `public.py` saves them via the shared magic-byte-validated `_save_photo()` (cap `MAX_REPORT_PHOTOS=5`) and stores the first in `Issue.photo_path`, the rest in `Issue.mobile_photo_paths` — never `result_photos` (rule 44), so they all render under "Photo Evidence". Do not add fields that leak internal detail, and do not reuse `render_template('base.html')` here — the public page is a standalone template with no authenticated nav. |
|
||||||
| 75 | **Email is stored lowercased; look it up case-insensitively** | User/customer email is normalized to `.strip().lower()` at every write site (`auth.py` profile/create/edit, `customers.py` invite/edit). Forgot-password lookup uses `db.func.lower(User.email) == input` so a mixed-case legacy row still matches — a plain `filter_by(email=...)` silently missed them and sent no reset (the failure was invisible because of the generic "if an account exists…" message). Keep both halves: normalize on write, case-insensitive on lookup. |
|
| 75 | **Email is stored lowercased; look it up case-insensitively** | User/customer email is normalized to `.strip().lower()` at every write site (`auth.py` profile/create/edit, `customers.py` invite/edit). Forgot-password lookup uses `db.func.lower(User.email) == input` so a mixed-case legacy row still matches — a plain `filter_by(email=...)` silently missed them and sent no reset (the failure was invisible because of the generic "if an account exists…" message). Keep both halves: normalize on write, case-insensitive on lookup. |
|
||||||
| 76 | **Transactional email `From` must be an SMTP-authorized identity, per-domain branding via display name only** | Reset-password sends from `MAIL_DEFAULT_SENDER`; customer invite sends from `branded_sender()` = `(per-domain display name, authorized address)`. A per-host `noreply@<subdomain>` sender is accepted by the relay then dropped by SPF/DMARC. See rule 64 and §8 `mail_utils.py`. |
|
| 76 | **Transactional email `From` must be an SMTP-authorized identity, per-domain branding via display name only** | Reset-password sends from `MAIL_DEFAULT_SENDER`; customer invite sends from `branded_sender()` = `(per-domain display name, authorized address)`. A per-host `noreply@<subdomain>` sender is accepted by the relay then dropped by SPF/DMARC. See rule 64 and §8 `mail_utils.py`. |
|
||||||
| 77 | **`GET /api/v1/scheduled-inspections` is inspector-scoped by `inspector_id`, admin/director/PM see all** | New `app/api/scheduled.py` blueprint. Register in `app/api/__init__.py` AND `csrf.exempt(_api_scheduled_bp)` in `app/__init__.py` — the child-blueprint CSRF exemption never cascades from the parent. Read-only; do not add write/fulfil endpoints here (the schedule lifecycle stays in `routes/scheduled_inspections.py`). |
|
| 77 | **`GET /api/v1/scheduled-inspections` is inspector-scoped by `inspector_id`, admin/director/PM see all** | New `app/api/scheduled.py` blueprint. Register in `app/api/__init__.py` AND `csrf.exempt(_api_scheduled_bp)` in `app/__init__.py` — the child-blueprint CSRF exemption never cascades from the parent. Read-only; do not add write/fulfil endpoints here (the schedule lifecycle stays in `routes/scheduled_inspections.py`). |
|
||||||
| 78 | **`PATCH /api/v1/issues/<id>/handler` allows the inspector on purpose — do NOT align it to the web form's admin/director/PM restriction** | The iPad lets the assigned inspector set "Handled By" from the field, scoped via `get_inspector_scope()` (403 if the issue's facility isn't contracted). This is a deliberate divergence from the web form. `_issue_payload()` must keep returning all 8 handler fields (`handler_type`, `handler_label`, `facility_handler_*`, `vendor_*`) or the iPad's "Handled By" panel silently blanks — same failure mode as rule 40. |
|
| 78 | **`PATCH /api/v1/issues/<id>/handler` allows the inspector on purpose — do NOT align it to the web form's admin/director/PM restriction** | The iPad lets the assigned inspector set "Handled By" from the field, scoped via `get_inspector_scope()` (403 if the issue's facility isn't contracted). This is a deliberate divergence from the web form. `_issue_payload()` must keep returning all handler fields (`handler_type`, `handler_label`, `facility_handler_*`, `vendor_*`, `internal_handler_name`, `internal_handler_contact`) or the iPad's "Handled By" panel silently blanks — same failure mode as rule 40. |
|
||||||
|
| 79 | **`auditor` = `project_manager` access + issue management, minus delete — keep the two decorators distinct** | Auditor is added to `@project_manager_required` (PM baseline) and to every `project_manager` role check in routes/templates. Its *extra* issue powers (verify/bulk-verify/verification-queue) go through the separate `@issue_manager_required` (admin/director/auditor). Issue **delete** stays `@supervisor_required` — never add auditor there. When adding a new PM-level gate, include `auditor`; when adding a director-only or delete-level gate, do not. The three issue **delete** template gates (spaced `['admin', 'director']` in `issues/list.html` + `issues/view.html`) are deliberately left without auditor. Auditor is also in the `_ALLOWED_ROLES` set of every `app/api/*` module — a **new** API blueprint's `_ALLOWED_ROLES` must include `auditor` for PM parity. |
|
||||||
|
| 80 | **Assignee dropdowns are `director`/`inspector`/`auditor` (admin removed, auditor added)** | The issue/inspection assignee `<select>`s query `User.role.in_([...])` — admin was removed and auditor added (the inspection flag-issue list also keeps `project_manager`). These lists control who can be *assigned*, distinct from who can *edit*. The issue-update route (`issues.view`) defensively appends any current `assigned_to` who is not in the set (e.g. a legacy admin assignment) to `form.assigned_to.choices` so saving the form never silently unassigns them. Do not remove that guard. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
bp = Blueprint('api_comments', __name__)
|
bp = Blueprint('api_comments', __name__)
|
||||||
|
|
||||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||||
|
|
||||||
|
|
||||||
def _comment_payload(comment: IssueComment) -> dict:
|
def _comment_payload(comment: IssueComment) -> dict:
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
bp = Blueprint('api_inspections', __name__)
|
bp = Blueprint('api_inspections', __name__)
|
||||||
|
|
||||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||||
|
|
||||||
|
|
||||||
def _merge_form_data(existing: dict, incoming: dict) -> dict:
|
def _merge_form_data(existing: dict, incoming: dict) -> dict:
|
||||||
|
|||||||
+7
-2
@@ -41,7 +41,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
bp = Blueprint('api_issues', __name__)
|
bp = Blueprint('api_issues', __name__)
|
||||||
|
|
||||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||||
_VALID_SEVERITY = {'low', 'medium', 'high', 'critical'}
|
_VALID_SEVERITY = {'low', 'medium', 'high', 'critical'}
|
||||||
_VALID_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'}
|
_VALID_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'}
|
||||||
_VALID_HANDLERS = {'internal', 'facility', 'vendor'}
|
_VALID_HANDLERS = {'internal', 'facility', 'vendor'}
|
||||||
@@ -109,6 +109,8 @@ def _issue_payload(issue):
|
|||||||
'vendor_name': issue.vendor_name or None,
|
'vendor_name': issue.vendor_name or None,
|
||||||
'vendor_contact': issue.vendor_contact or None,
|
'vendor_contact': issue.vendor_contact or None,
|
||||||
'vendor_notes': issue.vendor_notes or None,
|
'vendor_notes': issue.vendor_notes or None,
|
||||||
|
'internal_handler_name': issue.internal_handler_name or None,
|
||||||
|
'internal_handler_contact': issue.internal_handler_contact or None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -423,7 +425,9 @@ def update_issue_handler(issue_id):
|
|||||||
"facility_handler_notes": "...", // optional
|
"facility_handler_notes": "...", // optional
|
||||||
"vendor_name": "...", // optional (vendor handler)
|
"vendor_name": "...", // optional (vendor handler)
|
||||||
"vendor_contact": "...", // optional
|
"vendor_contact": "...", // optional
|
||||||
"vendor_notes": "..." // optional
|
"vendor_notes": "...", // optional
|
||||||
|
"internal_handler_name": "...", // optional (janitorial staff name)
|
||||||
|
"internal_handler_contact": "..." // optional (janitorial staff contact)
|
||||||
}
|
}
|
||||||
|
|
||||||
Only keys present in the body are updated; empty strings clear a field.
|
Only keys present in the body are updated; empty strings clear a field.
|
||||||
@@ -463,6 +467,7 @@ def update_issue_handler(issue_id):
|
|||||||
_text_fields = (
|
_text_fields = (
|
||||||
'facility_handler_name', 'facility_handler_contact', 'facility_handler_notes',
|
'facility_handler_name', 'facility_handler_contact', 'facility_handler_notes',
|
||||||
'vendor_name', 'vendor_contact', 'vendor_notes',
|
'vendor_name', 'vendor_contact', 'vendor_notes',
|
||||||
|
'internal_handler_name', 'internal_handler_contact',
|
||||||
)
|
)
|
||||||
for field in _text_fields:
|
for field in _text_fields:
|
||||||
if field in data:
|
if field in data:
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
|
|||||||
bp = Blueprint('api_photos', __name__)
|
bp = Blueprint('api_photos', __name__)
|
||||||
|
|
||||||
_ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'}
|
_ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'}
|
||||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||||
|
|
||||||
|
|
||||||
def _allowed_file(filename: str) -> bool:
|
def _allowed_file(filename: str) -> bool:
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
bp = Blueprint('api_scheduled', __name__)
|
bp = Blueprint('api_scheduled', __name__)
|
||||||
|
|
||||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||||
|
|
||||||
|
|
||||||
def _scheduled_payload(s):
|
def _scheduled_payload(s):
|
||||||
|
|||||||
+1
-1
@@ -40,7 +40,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
bp = Blueprint('api_stats', __name__)
|
bp = Blueprint('api_stats', __name__)
|
||||||
|
|
||||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/stats/dashboard', methods=['GET'])
|
@bp.route('/stats/dashboard', methods=['GET'])
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
|
|||||||
bp = Blueprint('api_templates', __name__)
|
bp = Blueprint('api_templates', __name__)
|
||||||
|
|
||||||
# Customer role cannot access template data — inspectors and above only
|
# Customer role cannot access template data — inspectors and above only
|
||||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||||
|
|
||||||
|
|
||||||
def _template_summary_payload(template: InspectionTemplate) -> dict:
|
def _template_summary_payload(template: InspectionTemplate) -> dict:
|
||||||
|
|||||||
@@ -95,6 +95,11 @@ class Issue(db.Model):
|
|||||||
facility_handler_name = db.Column(db.String(100), nullable=True)
|
facility_handler_name = db.Column(db.String(100), nullable=True)
|
||||||
facility_handler_contact = db.Column(db.String(200), nullable=True) # phone or email
|
facility_handler_contact = db.Column(db.String(200), nullable=True) # phone or email
|
||||||
facility_handler_notes = db.Column(db.Text, nullable=True)
|
facility_handler_notes = db.Column(db.Text, nullable=True)
|
||||||
|
# Free-text name of the janitorial staff member who will handle the issue,
|
||||||
|
# used when handler_type == 'internal'. Distinct from assigned_to (the JQC
|
||||||
|
# User who owns follow-up): the actual crew member may not be a system user.
|
||||||
|
internal_handler_name = db.Column(db.String(100), nullable=True)
|
||||||
|
internal_handler_contact = db.Column(db.String(200), nullable=True) # phone or email
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
# NOTE: Issue.area is provided by the backref on Area.issues (facility.py).
|
# NOTE: Issue.area is provided by the backref on Area.issues (facility.py).
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ role_key values
|
|||||||
admin — all users with role='admin'
|
admin — all users with role='admin'
|
||||||
director — all users with role='director'
|
director — all users with role='director'
|
||||||
inspector — all users with role='inspector'
|
inspector — all users with role='inspector'
|
||||||
|
EXCEPTION: for event 'inspection_completed', the inspector
|
||||||
|
column notifies ONLY the inspection's own inspector
|
||||||
|
(the submitter), not the whole inspector pool. Scoping is
|
||||||
|
applied in notify_by_matrix() via the inspection_id.
|
||||||
project_manager — all users with role='project_manager'
|
project_manager — all users with role='project_manager'
|
||||||
customer — all customer-portal users assigned to the relevant facility
|
customer — all customer-portal users assigned to the relevant facility
|
||||||
assignee — the specific user the issue/inspection is assigned to
|
assignee — the specific user the issue/inspection is assigned to
|
||||||
@@ -40,6 +44,7 @@ MATRIX_ROLES = [
|
|||||||
('director', 'Director'),
|
('director', 'Director'),
|
||||||
('inspector', 'Inspector'),
|
('inspector', 'Inspector'),
|
||||||
('project_manager', 'Project Manager'),
|
('project_manager', 'Project Manager'),
|
||||||
|
('auditor', 'Auditor'),
|
||||||
('customer', 'Customer'),
|
('customer', 'Customer'),
|
||||||
('custom', 'Custom Recipients'),
|
('custom', 'Custom Recipients'),
|
||||||
]
|
]
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ class User(UserMixin, db.Model):
|
|||||||
role = db.Column(
|
role = db.Column(
|
||||||
# Phase 11 migration complete — 'supervisor' removed from both the DB
|
# Phase 11 migration complete — 'supervisor' removed from both the DB
|
||||||
# ENUM and this Python-side declaration. Director is the canonical role.
|
# ENUM and this Python-side declaration. Director is the canonical role.
|
||||||
db.Enum('admin', 'director', 'inspector', 'project_manager', 'customer'),
|
db.Enum('admin', 'director', 'inspector', 'project_manager', 'customer', 'auditor'),
|
||||||
nullable=False
|
nullable=False
|
||||||
)
|
)
|
||||||
created_at = db.Column(db.DateTime, default=now_eastern)
|
created_at = db.Column(db.DateTime, default=now_eastern)
|
||||||
|
|||||||
+12
-6
@@ -34,11 +34,14 @@ def index():
|
|||||||
now = now_eastern()
|
now = now_eastern()
|
||||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
today_end = today_start + timedelta(days=1)
|
today_end = today_start + timedelta(days=1)
|
||||||
|
# Start of the current week (Monday 00:00) for the "Submitted This Week" card.
|
||||||
|
week_start = today_start - timedelta(days=today_start.weekday())
|
||||||
|
|
||||||
is_inspector = current_user.role == 'inspector'
|
is_inspector = current_user.role == 'inspector'
|
||||||
is_privileged = current_user.role in ['admin', 'director']
|
is_privileged = current_user.role in ['admin', 'director']
|
||||||
is_customer = current_user.role == 'customer'
|
is_customer = current_user.role == 'customer'
|
||||||
is_project_manager = current_user.role == 'project_manager'
|
is_project_manager = current_user.role == 'project_manager'
|
||||||
|
is_auditor = current_user.role == 'auditor'
|
||||||
|
|
||||||
# Resolve facility scope
|
# Resolve facility scope
|
||||||
customer_facility_ids = get_customer_scope(current_user) # None for non-customers
|
customer_facility_ids = get_customer_scope(current_user) # None for non-customers
|
||||||
@@ -60,14 +63,16 @@ def index():
|
|||||||
else:
|
else:
|
||||||
base_q = base_q.filter(Inspection.facility_id.in_(customer_facility_ids))
|
base_q = base_q.filter(Inspection.facility_id.in_(customer_facility_ids))
|
||||||
|
|
||||||
today_inspections = base_q.filter(
|
completed_today = base_q.filter(
|
||||||
|
Inspection.status == 'completed',
|
||||||
Inspection.inspection_date >= today_start,
|
Inspection.inspection_date >= today_start,
|
||||||
Inspection.inspection_date < today_end,
|
Inspection.inspection_date < today_end,
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
completed_today = base_q.filter(
|
# Fully completed & submitted so far this week (Monday → now).
|
||||||
|
submitted_this_week = base_q.filter(
|
||||||
Inspection.status == 'completed',
|
Inspection.status == 'completed',
|
||||||
Inspection.inspection_date >= today_start,
|
Inspection.inspection_date >= week_start,
|
||||||
Inspection.inspection_date < today_end,
|
Inspection.inspection_date < today_end,
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
@@ -309,9 +314,9 @@ def index():
|
|||||||
unassigned_open = len(unassigned_all)
|
unassigned_open = len(unassigned_all)
|
||||||
unassigned_handler = _handler_split(unassigned_all)
|
unassigned_handler = _handler_split(unassigned_all)
|
||||||
|
|
||||||
# ── Inspector activity today (admin / director / PM only) ─────────────────
|
# ── Inspector activity today (admin / director / PM / auditor only) ───────
|
||||||
inspector_activity = []
|
inspector_activity = []
|
||||||
if is_privileged or is_project_manager:
|
if is_privileged or is_project_manager or is_auditor:
|
||||||
active_inspectors = (
|
active_inspectors = (
|
||||||
User.query
|
User.query
|
||||||
.filter_by(role='inspector', active=True)
|
.filter_by(role='inspector', active=True)
|
||||||
@@ -374,7 +379,7 @@ def index():
|
|||||||
'dashboard.html',
|
'dashboard.html',
|
||||||
sched_upcoming = sched_upcoming,
|
sched_upcoming = sched_upcoming,
|
||||||
sched_overdue_count = sched_overdue_count,
|
sched_overdue_count = sched_overdue_count,
|
||||||
today_inspections = today_inspections,
|
submitted_this_week = submitted_this_week,
|
||||||
completed_today = completed_today,
|
completed_today = completed_today,
|
||||||
open_issues = open_issues,
|
open_issues = open_issues,
|
||||||
severity_breakdown = severity_breakdown,
|
severity_breakdown = severity_breakdown,
|
||||||
@@ -397,6 +402,7 @@ def index():
|
|||||||
customer_facilities = customer_facilities,
|
customer_facilities = customer_facilities,
|
||||||
my_issues = my_issues,
|
my_issues = my_issues,
|
||||||
today_str = now.strftime('%Y-%m-%d'),
|
today_str = now.strftime('%Y-%m-%d'),
|
||||||
|
week_start_str = week_start.strftime('%Y-%m-%d'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+122
-15
@@ -6,7 +6,7 @@ from app.models.facility import Facility, Area
|
|||||||
from app.models.project import Project
|
from app.models.project import Project
|
||||||
from app.utils.forms import FacilityForm, AreaForm
|
from app.utils.forms import FacilityForm, AreaForm
|
||||||
from app.utils.decorators import supervisor_required, admin_required
|
from app.utils.decorators import supervisor_required, admin_required
|
||||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
|
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT
|
||||||
from app.utils.scope import get_customer_scope, get_inspector_scope
|
from app.utils.scope import get_customer_scope, get_inspector_scope
|
||||||
|
|
||||||
bp = Blueprint('facilities', __name__, url_prefix='/facilities')
|
bp = Blueprint('facilities', __name__, url_prefix='/facilities')
|
||||||
@@ -186,45 +186,152 @@ def facility_qr_regenerate(facility_id):
|
|||||||
return redirect(url_for('facilities.facility_qr_page', facility_id=facility.id))
|
return redirect(url_for('facilities.facility_qr_page', facility_id=facility.id))
|
||||||
|
|
||||||
|
|
||||||
|
def _qr_png_bytes(url):
|
||||||
|
"""Return PNG bytes for a QR code encoding *url* (same params as qr.png)."""
|
||||||
|
import io
|
||||||
|
import qrcode
|
||||||
|
img = qrcode.make(url, box_size=10, border=2)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format='PNG')
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/qr/print-all')
|
@bp.route('/qr/print-all')
|
||||||
@login_required
|
@login_required
|
||||||
def facility_qr_print_all():
|
def facility_qr_print_all():
|
||||||
"""Printable sheet of QR codes for all facilities the user can see.
|
"""Printable / selectable sheet of QR codes the user can see.
|
||||||
|
|
||||||
Optional ?contract_id=<id> limits the sheet to one contract. Inspectors have
|
Query params (all optional):
|
||||||
no QR management (403); customers are scoped to their assigned facilities;
|
?contract_id=<id> — limit to one contract; narrows the facility dropdown
|
||||||
managers see all active facilities.
|
?facility_id=<id> — limit to a single facility
|
||||||
|
?include_areas=1 — also render each facility's per-area QR codes
|
||||||
|
|
||||||
|
Inspectors have no QR management (403); customers are scoped to their
|
||||||
|
assigned facilities; managers see all active facilities.
|
||||||
"""
|
"""
|
||||||
# QR management is not an inspector task.
|
# QR management is not an inspector task.
|
||||||
if current_user.role == 'inspector':
|
if current_user.role == 'inspector':
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
contract_id = request.args.get('contract_id', type=int)
|
contract_id = request.args.get('contract_id', type=int)
|
||||||
|
facility_id = request.args.get('facility_id', type=int)
|
||||||
|
include_areas = request.args.get('include_areas') in ('1', 'true', 'on')
|
||||||
|
|
||||||
|
# Facilities in the viewer's scope.
|
||||||
if current_user.role == 'customer':
|
if current_user.role == 'customer':
|
||||||
fids = get_customer_scope(current_user) or []
|
fids = get_customer_scope(current_user) or []
|
||||||
query = Facility.query.filter(Facility.id.in_(fids), Facility.active == True)
|
scoped = Facility.query.filter(Facility.id.in_(fids), Facility.active == True)
|
||||||
else:
|
else:
|
||||||
query = Facility.query.filter(Facility.active == True)
|
scoped = Facility.query.filter(Facility.active == True)
|
||||||
|
scoped_facilities = scoped.order_by(Facility.name).all()
|
||||||
|
|
||||||
if contract_id:
|
# Contract dropdown — only contracts present among the scoped facilities.
|
||||||
query = query.filter(Facility.project_id == contract_id)
|
contract_ids = {f.project_id for f in scoped_facilities if f.project_id}
|
||||||
|
contracts = (Project.query
|
||||||
|
.filter(Project.id.in_(contract_ids))
|
||||||
|
.order_by(Project.name).all()) if contract_ids else []
|
||||||
|
|
||||||
facilities = query.order_by(Facility.name).all()
|
# Facility dropdown — narrowed by the selected contract.
|
||||||
|
facility_options = [f for f in scoped_facilities
|
||||||
|
if not contract_id or f.project_id == contract_id]
|
||||||
|
|
||||||
# Ensure every facility on the sheet has a token so its qr.png renders.
|
# The rendered grid — apply the contract + facility filters.
|
||||||
|
grid_facilities = facility_options
|
||||||
|
if facility_id:
|
||||||
|
grid_facilities = [f for f in grid_facilities if f.id == facility_id]
|
||||||
|
|
||||||
|
# Ensure every rendered facility (and area, if requested) has a token so
|
||||||
|
# its qr.png renders; collect areas keyed by facility id.
|
||||||
changed = False
|
changed = False
|
||||||
for f in facilities:
|
areas_by_facility = {}
|
||||||
|
for f in grid_facilities:
|
||||||
if not f.public_token:
|
if not f.public_token:
|
||||||
f.ensure_public_token()
|
f.ensure_public_token()
|
||||||
changed = True
|
changed = True
|
||||||
|
if include_areas:
|
||||||
|
fa = f.areas.order_by(Area.name).all()
|
||||||
|
for a in fa:
|
||||||
|
if not a.public_token:
|
||||||
|
a.ensure_public_token()
|
||||||
|
changed = True
|
||||||
|
areas_by_facility[f.id] = fa
|
||||||
if changed:
|
if changed:
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
selected_contract = db.session.get(Project, contract_id) if contract_id else None
|
selected_contract = db.session.get(Project, contract_id) if contract_id else None
|
||||||
return render_template('facilities/qr_print_all.html',
|
return render_template('facilities/qr_print_all.html',
|
||||||
facilities=facilities,
|
facilities=grid_facilities,
|
||||||
selected_contract=selected_contract)
|
areas_by_facility=areas_by_facility,
|
||||||
|
include_areas=include_areas,
|
||||||
|
contracts=contracts,
|
||||||
|
facility_options=facility_options,
|
||||||
|
selected_contract=selected_contract,
|
||||||
|
selected_contract_id=contract_id,
|
||||||
|
selected_facility_id=facility_id)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/qr/export-pdf', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def facility_qr_export_pdf():
|
||||||
|
"""Export the selected facility + area QR codes to a single PDF.
|
||||||
|
|
||||||
|
Selection arrives as repeated `facility_ids` / `area_ids` form fields.
|
||||||
|
Scope is enforced per-id via the same helpers as the QR pages, so a
|
||||||
|
customer can never export a code outside their assigned facilities.
|
||||||
|
"""
|
||||||
|
if current_user.role == 'inspector':
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
facility_ids = request.form.getlist('facility_ids', type=int)
|
||||||
|
area_ids = request.form.getlist('area_ids', type=int)
|
||||||
|
|
||||||
|
if not facility_ids and not area_ids:
|
||||||
|
flash('Select at least one QR code to export.', 'warning')
|
||||||
|
return redirect(request.referrer or url_for('facilities.facility_qr_print_all'))
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for fid in facility_ids:
|
||||||
|
facility = _facility_for_qr_or_403(fid) # 403 if out of scope
|
||||||
|
url = _public_facility_url(facility)
|
||||||
|
items.append({
|
||||||
|
'title': facility.name,
|
||||||
|
'subtitle': facility.project.name if facility.project else None,
|
||||||
|
'caption': 'Facility · Report a problem & view recent quality',
|
||||||
|
'png': _qr_png_bytes(url),
|
||||||
|
'_sort': ((facility.name or '').lower(), 0, ''),
|
||||||
|
})
|
||||||
|
for aid in area_ids:
|
||||||
|
area = _area_for_qr_or_403(aid) # 403 if out of scope
|
||||||
|
url = _public_area_url(area)
|
||||||
|
fac_name = area.facility.name if area.facility else ''
|
||||||
|
items.append({
|
||||||
|
'title': area.name,
|
||||||
|
'subtitle': fac_name or None,
|
||||||
|
'caption': 'Area · Report a problem & view recent quality',
|
||||||
|
'png': _qr_png_bytes(url),
|
||||||
|
'_sort': (fac_name.lower(), 1, (area.name or '').lower()),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Persist any tokens minted by ensure_public_token() above.
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Group each facility with its own areas: facility card first, then areas.
|
||||||
|
items.sort(key=lambda x: x['_sort'])
|
||||||
|
|
||||||
|
from app.utils.pdf_export import generate_qr_codes_pdf
|
||||||
|
summary = f'{len(facility_ids)} facilit' + ('y' if len(facility_ids) == 1 else 'ies')
|
||||||
|
summary += f', {len(area_ids)} area' + ('' if len(area_ids) == 1 else 's')
|
||||||
|
pdf_bytes = generate_qr_codes_pdf(items, filter_summary=summary)
|
||||||
|
|
||||||
|
logger.info('FACILITIES | qr_export_pdf | user=%s | facilities=%s | areas=%s',
|
||||||
|
current_user.username, len(facility_ids), len(area_ids))
|
||||||
|
log_action(ACTION_EXPORT, 'Facility', 0, 'QR Codes',
|
||||||
|
f'exported {len(facility_ids)} facility + {len(area_ids)} area QR codes to PDF')
|
||||||
|
|
||||||
|
from flask import Response
|
||||||
|
return Response(pdf_bytes, mimetype='application/pdf', headers={
|
||||||
|
'Content-Disposition': 'attachment; filename="qr_codes.pdf"',
|
||||||
|
})
|
||||||
|
|
||||||
# ── Public Area QR code ───────────────────────────────────────────────────────
|
# ── Public Area QR code ───────────────────────────────────────────────────────
|
||||||
# Mirrors the facility QR routes above, but scoped to a single area. Customer
|
# Mirrors the facility QR routes above, but scoped to a single area. Customer
|
||||||
|
|||||||
@@ -608,7 +608,7 @@ def execute(inspection_id):
|
|||||||
return redirect(url_for('inspections.execute', inspection_id=inspection_id))
|
return redirect(url_for('inspections.execute', inspection_id=inspection_id))
|
||||||
|
|
||||||
staff_for_flag_issue = User.query.filter(
|
staff_for_flag_issue = User.query.filter(
|
||||||
User.role.in_(['admin', 'director', 'inspector', 'project_manager']),
|
User.role.in_(['director', 'inspector', 'project_manager', 'auditor']),
|
||||||
User.active == True,
|
User.active == True,
|
||||||
).order_by(User.full_name, User.username).all()
|
).order_by(User.full_name, User.username).all()
|
||||||
|
|
||||||
|
|||||||
+26
-14
@@ -15,7 +15,7 @@ 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
|
from app.utils.decorators import supervisor_required, issue_manager_required
|
||||||
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
|
||||||
@@ -344,7 +344,7 @@ def index():
|
|||||||
|
|
||||||
# Staff for quick-assign dropdown — same roles as the full issue form
|
# Staff for quick-assign dropdown — same roles as the full issue form
|
||||||
staff = User.query.filter(
|
staff = User.query.filter(
|
||||||
User.role.in_(['admin', 'director', 'inspector']), User.active == True
|
User.role.in_(['director', 'inspector', 'auditor']), User.active == True
|
||||||
).order_by(User.username).all()
|
).order_by(User.username).all()
|
||||||
|
|
||||||
# Reporters dropdown — users who have actually filed at least one issue
|
# Reporters dropdown — users who have actually filed at least one issue
|
||||||
@@ -419,7 +419,14 @@ def view(issue_id):
|
|||||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||||
|
|
||||||
form = IssueUpdateForm(obj=issue)
|
form = IssueUpdateForm(obj=issue)
|
||||||
staff = User.query.filter(User.role.in_(['admin', 'director', 'inspector'])).order_by(User.username).all()
|
staff = User.query.filter(User.role.in_(['director', 'inspector', 'auditor'])).order_by(User.username).all()
|
||||||
|
# Preserve any pre-existing assignee who is no longer in the assignable set
|
||||||
|
# (e.g. an admin assigned before admins were removed from the dropdown) so
|
||||||
|
# saving the form doesn't silently unassign them.
|
||||||
|
if issue.assigned_to and issue.assigned_to not in [u.id for u in staff]:
|
||||||
|
current_assignee = db.session.get(User, issue.assigned_to)
|
||||||
|
if current_assignee:
|
||||||
|
staff.append(current_assignee)
|
||||||
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.display_name) for u in staff]
|
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.display_name) for u in staff]
|
||||||
form.status.data = form.status.data or issue.status
|
form.status.data = form.status.data or issue.status
|
||||||
|
|
||||||
@@ -429,7 +436,7 @@ def view(issue_id):
|
|||||||
|
|
||||||
issue.status = form.status.data
|
issue.status = form.status.data
|
||||||
|
|
||||||
if current_user.role in ['admin', 'director']:
|
if current_user.role in ['admin', 'director', 'auditor']:
|
||||||
issue.assigned_to = form.assigned_to.data or None
|
issue.assigned_to = form.assigned_to.data or None
|
||||||
|
|
||||||
if form.status.data == 'resolved' and not issue.resolved_at:
|
if form.status.data == 'resolved' and not issue.resolved_at:
|
||||||
@@ -447,8 +454,8 @@ def view(issue_id):
|
|||||||
issue.result_notes = form.result_notes.data or None
|
issue.result_notes = form.result_notes.data or None
|
||||||
|
|
||||||
# Handler assignment (who handles it) + vendor/facility details —
|
# Handler assignment (who handles it) + vendor/facility details —
|
||||||
# admin, director, project_manager only.
|
# admin, director, project_manager, auditor only.
|
||||||
if current_user.role in ('admin', 'director', 'project_manager'):
|
if current_user.role in ('admin', 'director', 'project_manager', 'auditor'):
|
||||||
handler = form.handler_type.data or 'internal'
|
handler = form.handler_type.data or 'internal'
|
||||||
if handler not in ('internal', 'facility', 'vendor'):
|
if handler not in ('internal', 'facility', 'vendor'):
|
||||||
handler = 'internal'
|
handler = 'internal'
|
||||||
@@ -462,6 +469,9 @@ def view(issue_id):
|
|||||||
issue.facility_handler_contact = (form.facility_handler_contact.data or '').strip() or None
|
issue.facility_handler_contact = (form.facility_handler_contact.data or '').strip() or None
|
||||||
issue.facility_handler_notes = (form.facility_handler_notes.data or '').strip() or None
|
issue.facility_handler_notes = (form.facility_handler_notes.data or '').strip() or None
|
||||||
|
|
||||||
|
issue.internal_handler_name = (form.internal_handler_name.data or '').strip() or None
|
||||||
|
issue.internal_handler_contact = (form.internal_handler_contact.data or '').strip() or None
|
||||||
|
|
||||||
from app.routes.inspections import _save_photo
|
from app.routes.inspections import _save_photo
|
||||||
new_photos = []
|
new_photos = []
|
||||||
for file_obj in request.files.getlist('result_photos'):
|
for file_obj in request.files.getlist('result_photos'):
|
||||||
@@ -690,7 +700,7 @@ def unfollow(issue_id):
|
|||||||
@bp.route('/new', methods=['GET', 'POST'])
|
@bp.route('/new', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def create():
|
def create():
|
||||||
if current_user.role not in ('admin', 'director', 'customer'):
|
if current_user.role not in ('admin', 'director', 'customer', 'auditor'):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
from app.models.project import Project, CustomerAssignment
|
from app.models.project import Project, CustomerAssignment
|
||||||
@@ -712,7 +722,7 @@ def create():
|
|||||||
else:
|
else:
|
||||||
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
||||||
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||||
staff = User.query.filter(User.role.in_(['admin', 'director', 'inspector'])).order_by(User.username).all()
|
staff = User.query.filter(User.role.in_(['director', 'inspector', 'auditor'])).order_by(User.username).all()
|
||||||
|
|
||||||
form.facility_id.choices = [(f.id, f.name) for f in facilities]
|
form.facility_id.choices = [(f.id, f.name) for f in facilities]
|
||||||
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.display_name) for u in staff]
|
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.display_name) for u in staff]
|
||||||
@@ -752,6 +762,8 @@ def create():
|
|||||||
issue.vendor_name = (form.vendor_name.data or '').strip() or None
|
issue.vendor_name = (form.vendor_name.data or '').strip() or None
|
||||||
issue.vendor_contact = (form.vendor_contact.data or '').strip() or None
|
issue.vendor_contact = (form.vendor_contact.data or '').strip() or None
|
||||||
issue.vendor_notes = (form.vendor_notes.data or '').strip() or None
|
issue.vendor_notes = (form.vendor_notes.data or '').strip() or None
|
||||||
|
issue.internal_handler_name = (form.internal_handler_name.data or '').strip() or None
|
||||||
|
issue.internal_handler_contact = (form.internal_handler_contact.data or '').strip() or None
|
||||||
|
|
||||||
db.session.add(issue)
|
db.session.add(issue)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -812,7 +824,7 @@ def create():
|
|||||||
|
|
||||||
@bp.route('/<int:issue_id>/verify', methods=['POST'])
|
@bp.route('/<int:issue_id>/verify', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@supervisor_required
|
@issue_manager_required
|
||||||
def verify(issue_id):
|
def verify(issue_id):
|
||||||
"""Supervisor sign-off: confirms resolution is satisfactory and closes the issue."""
|
"""Supervisor sign-off: confirms resolution is satisfactory and closes the issue."""
|
||||||
issue = db.session.get(Issue, issue_id)
|
issue = db.session.get(Issue, issue_id)
|
||||||
@@ -846,7 +858,7 @@ def verify(issue_id):
|
|||||||
|
|
||||||
@bp.route('/bulk-verify', methods=['POST'])
|
@bp.route('/bulk-verify', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@supervisor_required
|
@issue_manager_required
|
||||||
def bulk_verify():
|
def bulk_verify():
|
||||||
"""Verify multiple pending-verification issues in a single action."""
|
"""Verify multiple pending-verification issues in a single action."""
|
||||||
issue_ids = request.form.getlist('issue_ids', type=int)
|
issue_ids = request.form.getlist('issue_ids', type=int)
|
||||||
@@ -891,9 +903,9 @@ def request_verification(issue_id):
|
|||||||
flash('Access denied.', 'danger')
|
flash('Access denied.', 'danger')
|
||||||
return redirect(url_for('issues.index'))
|
return redirect(url_for('issues.index'))
|
||||||
|
|
||||||
# Only the assignee, director, or admin can request verification
|
# Only the assignee, director, admin, or auditor can request verification
|
||||||
can_act = (
|
can_act = (
|
||||||
current_user.role in ['admin', 'director']
|
current_user.role in ['admin', 'director', 'auditor']
|
||||||
or issue.assigned_to == current_user.id
|
or issue.assigned_to == current_user.id
|
||||||
)
|
)
|
||||||
if not can_act:
|
if not can_act:
|
||||||
@@ -936,7 +948,7 @@ def request_verification(issue_id):
|
|||||||
|
|
||||||
@bp.route('/verification-queue')
|
@bp.route('/verification-queue')
|
||||||
@login_required
|
@login_required
|
||||||
@supervisor_required
|
@issue_manager_required
|
||||||
def verification_queue():
|
def verification_queue():
|
||||||
"""Supervisor queue of all issues awaiting verification, grouped by facility."""
|
"""Supervisor queue of all issues awaiting verification, grouped by facility."""
|
||||||
from app.models.facility import Facility, Area
|
from app.models.facility import Facility, Area
|
||||||
@@ -1032,7 +1044,7 @@ def delete(issue_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def quick_assign(issue_id):
|
def quick_assign(issue_id):
|
||||||
"""Inline assignee update from the issues list — returns JSON."""
|
"""Inline assignee update from the issues list — returns JSON."""
|
||||||
if current_user.role not in ('admin', 'director'):
|
if current_user.role not in ('admin', 'director', 'auditor'):
|
||||||
return jsonify({'ok': False, 'error': 'Permission denied'}), 403
|
return jsonify({'ok': False, 'error': 'Permission denied'}), 403
|
||||||
|
|
||||||
issue = db.session.get(Issue, issue_id)
|
issue = db.session.get(Issue, issue_id)
|
||||||
|
|||||||
+82
-11
@@ -38,6 +38,55 @@ logger = logging.getLogger(__name__)
|
|||||||
bp = Blueprint('public', __name__, url_prefix='/f')
|
bp = Blueprint('public', __name__, url_prefix='/f')
|
||||||
|
|
||||||
|
|
||||||
|
#: Maximum number of photos an occupant may attach to a public report.
|
||||||
|
MAX_REPORT_PHOTOS = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _save_report_photos(file_list):
|
||||||
|
"""Save up to MAX_REPORT_PHOTOS uploaded photos from a public report.
|
||||||
|
|
||||||
|
Returns (photo_path, extra_paths) where photo_path is the primary evidence
|
||||||
|
photo (or None) and extra_paths is a list of the remaining paths (or None).
|
||||||
|
Splitting this way mirrors the Issue photo model: the first photo lives in
|
||||||
|
`photo_path`, the rest in `mobile_photo_paths` so they all render together
|
||||||
|
under "Photo Evidence" on the web (rule 44 — never `result_photos`).
|
||||||
|
"""
|
||||||
|
from app.routes.inspections import _save_photo
|
||||||
|
saved = []
|
||||||
|
for f in (file_list or [])[:MAX_REPORT_PHOTOS]:
|
||||||
|
path = _save_photo(f, subfolder='issue_photos')
|
||||||
|
if path:
|
||||||
|
saved.append(path)
|
||||||
|
photo_path = saved[0] if saved else None
|
||||||
|
extra_paths = saved[1:] if len(saved) > 1 else None
|
||||||
|
return photo_path, extra_paths
|
||||||
|
|
||||||
|
|
||||||
|
#: Window within which an identical public report is treated as a duplicate.
|
||||||
|
DUPLICATE_REPORT_WINDOW_SECONDS = 60
|
||||||
|
|
||||||
|
|
||||||
|
def _recent_duplicate_report(facility_id, area_id, description):
|
||||||
|
"""Return True if an identical public report was just filed.
|
||||||
|
|
||||||
|
Belt-and-suspenders against duplicate submissions (double-taps, JS-disabled
|
||||||
|
clients, retries): if a public issue (reported_by IS NULL) with the same
|
||||||
|
facility/area and identical description was created within the last
|
||||||
|
DUPLICATE_REPORT_WINDOW_SECONDS, treat this one as a duplicate and skip it.
|
||||||
|
"""
|
||||||
|
cutoff = now_eastern() - timedelta(seconds=DUPLICATE_REPORT_WINDOW_SECONDS)
|
||||||
|
q = Issue.query.filter(
|
||||||
|
Issue.reported_by.is_(None),
|
||||||
|
Issue.reported_at >= cutoff,
|
||||||
|
Issue.description == description,
|
||||||
|
)
|
||||||
|
if area_id is not None:
|
||||||
|
q = q.filter(Issue.area_id == area_id)
|
||||||
|
else:
|
||||||
|
q = q.filter(Issue.facility_id == facility_id, Issue.area_id.is_(None))
|
||||||
|
return db.session.query(q.exists()).scalar()
|
||||||
|
|
||||||
|
|
||||||
def _facility_by_token_or_404(token: str) -> Facility:
|
def _facility_by_token_or_404(token: str) -> Facility:
|
||||||
"""Resolve an ACTIVE facility from its public token, else 404."""
|
"""Resolve an ACTIVE facility from its public token, else 404."""
|
||||||
if not token:
|
if not token:
|
||||||
@@ -320,10 +369,6 @@ def report_problem(token):
|
|||||||
return render_template('public/facility.html',
|
return render_template('public/facility.html',
|
||||||
form=form, token=token, **summary), 400
|
form=form, token=token, **summary), 400
|
||||||
|
|
||||||
# Save optional photo through the shared, magic-byte-validated saver.
|
|
||||||
from app.routes.inspections import _save_photo
|
|
||||||
photo_path = _save_photo(form.photo.data, subfolder='issue_photos')
|
|
||||||
|
|
||||||
# Fold optional reporter identity + location into the description; the
|
# Fold optional reporter identity + location into the description; the
|
||||||
# public reporter is not a User, so reported_by stays NULL.
|
# public reporter is not a User, so reported_by stays NULL.
|
||||||
parts = ['[Reported via facility QR code]']
|
parts = ['[Reported via facility QR code]']
|
||||||
@@ -336,12 +381,26 @@ def report_problem(token):
|
|||||||
parts.append(form.description.data.strip())
|
parts.append(form.description.data.strip())
|
||||||
description = '\n'.join(parts)
|
description = '\n'.join(parts)
|
||||||
|
|
||||||
|
# Server-side idempotency: silently accept an identical repeat as success
|
||||||
|
# without creating a second issue (or saving its photos).
|
||||||
|
if _recent_duplicate_report(facility.id, None, description):
|
||||||
|
logger.info('PUBLIC REPORT | duplicate suppressed | facility_id=%s | ip=%s',
|
||||||
|
facility.id, request.remote_addr)
|
||||||
|
flash('Thank you — your report has been received and the team has been notified.',
|
||||||
|
'success')
|
||||||
|
return redirect(url_for('public.facility_summary', token=token))
|
||||||
|
|
||||||
|
# Save up to 5 optional photos through the shared, magic-byte-validated
|
||||||
|
# saver. First → photo_path, the rest → mobile_photo_paths.
|
||||||
|
photo_path, extra_photos = _save_report_photos(form.photos.data)
|
||||||
|
|
||||||
issue = Issue(
|
issue = Issue(
|
||||||
facility_id = facility.id,
|
facility_id = facility.id,
|
||||||
area_id = None,
|
area_id = None,
|
||||||
severity = 'medium',
|
severity = 'medium',
|
||||||
description = description,
|
description = description,
|
||||||
photo_path = photo_path,
|
photo_path = photo_path,
|
||||||
|
mobile_photo_paths = extra_photos,
|
||||||
status = 'open',
|
status = 'open',
|
||||||
reported_at = now_eastern(),
|
reported_at = now_eastern(),
|
||||||
reported_by = None,
|
reported_by = None,
|
||||||
@@ -349,8 +408,9 @@ def report_problem(token):
|
|||||||
db.session.add(issue)
|
db.session.add(issue)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
logger.info('PUBLIC REPORT | issue_id=%s | facility_id=%s | ip=%s | photo=%s',
|
_photo_count = (1 if photo_path else 0) + (len(extra_photos) if extra_photos else 0)
|
||||||
issue.id, facility.id, request.remote_addr, bool(photo_path))
|
logger.info('PUBLIC REPORT | issue_id=%s | facility_id=%s | ip=%s | photos=%s',
|
||||||
|
issue.id, facility.id, request.remote_addr, _photo_count)
|
||||||
|
|
||||||
# Reuse the standard issue-created routing (staff + facility customers).
|
# Reuse the standard issue-created routing (staff + facility customers).
|
||||||
notify_by_matrix(
|
notify_by_matrix(
|
||||||
@@ -390,9 +450,6 @@ def area_report_problem(token):
|
|||||||
return render_template('public/area.html',
|
return render_template('public/area.html',
|
||||||
form=form, token=token, **summary), 400
|
form=form, token=token, **summary), 400
|
||||||
|
|
||||||
from app.routes.inspections import _save_photo
|
|
||||||
photo_path = _save_photo(form.photo.data, subfolder='issue_photos')
|
|
||||||
|
|
||||||
# The area is known from the QR token, so we set area_id directly and note
|
# The area is known from the QR token, so we set area_id directly and note
|
||||||
# the source. A public reporter is not a User, so reported_by stays NULL.
|
# the source. A public reporter is not a User, so reported_by stays NULL.
|
||||||
parts = [f'[Reported via area QR code — {area.name}]']
|
parts = [f'[Reported via area QR code — {area.name}]']
|
||||||
@@ -405,12 +462,25 @@ def area_report_problem(token):
|
|||||||
parts.append(form.description.data.strip())
|
parts.append(form.description.data.strip())
|
||||||
description = '\n'.join(parts)
|
description = '\n'.join(parts)
|
||||||
|
|
||||||
|
# Server-side idempotency: silently accept an identical repeat as success
|
||||||
|
# without creating a second issue (or saving its photos).
|
||||||
|
if _recent_duplicate_report(facility.id, area.id, description):
|
||||||
|
logger.info('PUBLIC REPORT | duplicate suppressed | area_id=%s | facility_id=%s | ip=%s',
|
||||||
|
area.id, facility.id, request.remote_addr)
|
||||||
|
flash('Thank you — your report has been received and the team has been notified.',
|
||||||
|
'success')
|
||||||
|
return redirect(url_for('public.area_summary', token=token))
|
||||||
|
|
||||||
|
# Save up to 5 optional photos (first → photo_path, rest → mobile_photo_paths).
|
||||||
|
photo_path, extra_photos = _save_report_photos(form.photos.data)
|
||||||
|
|
||||||
issue = Issue(
|
issue = Issue(
|
||||||
facility_id = facility.id,
|
facility_id = facility.id,
|
||||||
area_id = area.id,
|
area_id = area.id,
|
||||||
severity = 'medium',
|
severity = 'medium',
|
||||||
description = description,
|
description = description,
|
||||||
photo_path = photo_path,
|
photo_path = photo_path,
|
||||||
|
mobile_photo_paths = extra_photos,
|
||||||
status = 'open',
|
status = 'open',
|
||||||
reported_at = now_eastern(),
|
reported_at = now_eastern(),
|
||||||
reported_by = None,
|
reported_by = None,
|
||||||
@@ -418,8 +488,9 @@ def area_report_problem(token):
|
|||||||
db.session.add(issue)
|
db.session.add(issue)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
logger.info('PUBLIC REPORT | issue_id=%s | area_id=%s | facility_id=%s | ip=%s | photo=%s',
|
_photo_count = (1 if photo_path else 0) + (len(extra_photos) if extra_photos else 0)
|
||||||
issue.id, area.id, facility.id, request.remote_addr, bool(photo_path))
|
logger.info('PUBLIC REPORT | issue_id=%s | area_id=%s | facility_id=%s | ip=%s | photos=%s',
|
||||||
|
issue.id, area.id, facility.id, request.remote_addr, _photo_count)
|
||||||
|
|
||||||
notify_by_matrix(
|
notify_by_matrix(
|
||||||
event_type = 'issue_created',
|
event_type = 'issue_created',
|
||||||
|
|||||||
@@ -95,7 +95,7 @@
|
|||||||
{# Row 1: spanning group headers #}
|
{# Row 1: spanning group headers #}
|
||||||
<tr>
|
<tr>
|
||||||
<th class="event-col" rowspan="2" style="min-width:200px;">Event</th>
|
<th class="event-col" rowspan="2" style="min-width:200px;">Event</th>
|
||||||
<th class="group-hdr" colspan="4">Internal Recipients</th>
|
<th class="group-hdr" colspan="5">Internal Recipients</th>
|
||||||
<th class="group-hdr" colspan="1">Customer</th>
|
<th class="group-hdr" colspan="1">Customer</th>
|
||||||
<th class="group-hdr" colspan="1">Custom Recipients</th>
|
<th class="group-hdr" colspan="1">Custom Recipients</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
<td>{{ user.full_name or '—' }}</td>
|
<td>{{ user.full_name or '—' }}</td>
|
||||||
<td>{{ user.email }}</td>
|
<td>{{ user.email }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge bg-{% if user.role == 'admin' %}danger{% elif user.role == 'director' %}warning{% elif user.role == 'project_manager' %}primary{% elif user.role == 'customer' %}success{% else %}info{% endif %}">
|
<span class="badge bg-{% if user.role == 'admin' %}danger{% elif user.role == 'director' %}warning{% elif user.role == 'project_manager' %}primary{% elif user.role == 'auditor' %}secondary{% elif user.role == 'customer' %}success{% else %}info{% endif %}">
|
||||||
{{ user.role.replace('_',' ')|title }}
|
{{ user.role.replace('_',' ')|title }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -119,7 +119,7 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('reports.') or request.endpoint.startswith('scheduled_reports.')) }}" href="{{ url_for('reports.index') }}">Reports</a>
|
<a class="nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('reports.') or request.endpoint.startswith('scheduled_reports.')) }}" href="{{ url_for('reports.index') }}">Reports</a>
|
||||||
</li>
|
</li>
|
||||||
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
|
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}" href="{{ url_for('projects.index') }}">Contracts</a>
|
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}" href="{{ url_for('projects.index') }}">Contracts</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -138,7 +138,7 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('issues.') and request.endpoint != 'issues.verification_queue' }}" href="{{ url_for('issues.index') }}">Issues</a>
|
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('issues.') and request.endpoint != 'issues.verification_queue' }}" href="{{ url_for('issues.index') }}">Issues</a>
|
||||||
</li>
|
</li>
|
||||||
{% if current_user.role in ['admin', 'director'] %}
|
{% if current_user.role in ['admin', 'director', 'auditor'] %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link d-flex align-items-center gap-1 {{ 'active' if request.endpoint == 'issues.verification_queue' }}"
|
<a class="nav-link d-flex align-items-center gap-1 {{ 'active' if request.endpoint == 'issues.verification_queue' }}"
|
||||||
href="{{ url_for('issues.verification_queue') }}">
|
href="{{ url_for('issues.verification_queue') }}">
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<div class="row mb-3 align-items-center">
|
<div class="row mb-3 align-items-center">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h2 class="mb-0">Welcome, {{ current_user.display_name }}!</h2>
|
<h2 class="mb-0">Welcome, {{ current_user.display_name }}!</h2>
|
||||||
<span class="badge bg-{% if current_user.role == 'admin' %}danger{% elif current_user.role == 'director' %}warning{% elif current_user.role == 'project_manager' %}primary{% elif current_user.role == 'customer' %}success{% else %}info{% endif %} mt-1">
|
<span class="badge bg-{% if current_user.role == 'admin' %}danger{% elif current_user.role == 'director' %}warning{% elif current_user.role == 'project_manager' %}primary{% elif current_user.role == 'auditor' %}secondary{% elif current_user.role == 'customer' %}success{% else %}info{% endif %} mt-1">
|
||||||
{{ current_user.role.replace('_',' ')|title }}
|
{{ current_user.role.replace('_',' ')|title }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
<td class="small">{{ s.inspector.display_name if s.inspector else '—' }}</td>
|
<td class="small">{{ s.inspector.display_name if s.inspector else '—' }}</td>
|
||||||
<td class="small">{{ s.next_due_date.strftime('%b %d') }}</td>
|
<td class="small">{{ s.next_due_date.strftime('%b %d') }}</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
{% if current_user.role in ['admin','director','project_manager']
|
{% if current_user.role in ['admin','director','project_manager','auditor']
|
||||||
or (current_user.role == 'inspector' and s.inspector_id == current_user.id) %}
|
or (current_user.role == 'inspector' and s.inspector_id == current_user.id) %}
|
||||||
<a href="{{ url_for('scheduled_inspections.start', schedule_id=s.id) }}"
|
<a href="{{ url_for('scheduled_inspections.start', schedule_id=s.id) }}"
|
||||||
class="btn btn-sm btn-success py-0"><i class="bi bi-play-fill"></i> Start</a>
|
class="btn btn-sm btn-success py-0"><i class="bi bi-play-fill"></i> Start</a>
|
||||||
@@ -84,21 +84,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
|
|
||||||
<div class="col-6 col-md">
|
|
||||||
<a href="{{ url_for('inspections.index', date_from=today_str, date_to=today_str) }}" class="text-decoration-none">
|
|
||||||
<div class="card text-white bg-primary h-100">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
|
||||||
<span class="small text-white-50 fw-semibold">Today's Inspections</span>
|
|
||||||
<i class="bi bi-clipboard-data" style="font-size:1.4rem;opacity:.3;"></i>
|
|
||||||
</div>
|
|
||||||
<div class="fs-1 fw-bold lh-1">{{ today_inspections }}</div>
|
|
||||||
<div class="mt-2" style="font-size:.72rem;opacity:.7;">All inspections started today</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-6 col-md">
|
<div class="col-6 col-md">
|
||||||
<a href="{{ url_for('inspections.index', status='completed', date_from=today_str, date_to=today_str) }}" class="text-decoration-none">
|
<a href="{{ url_for('inspections.index', status='completed', date_from=today_str, date_to=today_str) }}" class="text-decoration-none">
|
||||||
<div class="card text-white bg-success h-100">
|
<div class="card text-white bg-success h-100">
|
||||||
@@ -114,6 +99,21 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6 col-md">
|
||||||
|
<a href="{{ url_for('inspections.index', status='completed', date_from=week_start_str, date_to=today_str) }}" class="text-decoration-none">
|
||||||
|
<div class="card text-white bg-primary h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||||
|
<span class="small text-white-50 fw-semibold">Submitted This Week</span>
|
||||||
|
<i class="bi bi-calendar-check" style="font-size:1.4rem;opacity:.3;"></i>
|
||||||
|
</div>
|
||||||
|
<div class="fs-1 fw-bold lh-1">{{ submitted_this_week }}</div>
|
||||||
|
<div class="mt-2" style="font-size:.72rem;opacity:.7;">Fully completed & submitted this week</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if current_user.role != 'customer' %}
|
{% if current_user.role != 'customer' %}
|
||||||
<div class="col-6 col-md">
|
<div class="col-6 col-md">
|
||||||
<a href="{{ url_for('inspections.index', status='in_progress') }}" class="text-decoration-none">
|
<a href="{{ url_for('inspections.index', status='in_progress') }}" class="text-decoration-none">
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</button>
|
</button>
|
||||||
<span class="badge bg-secondary ms-2">{{ group.facilities|length }}</span>
|
<span class="badge bg-secondary ms-2">{{ group.facilities|length }}</span>
|
||||||
{% if group.project and current_user.role in ['admin', 'director', 'project_manager'] %}
|
{% if group.project and current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||||
<a href="{{ url_for('projects.view', project_id=group.project.id) }}"
|
<a href="{{ url_for('projects.view', project_id=group.project.id) }}"
|
||||||
class="btn btn-sm btn-outline-secondary ms-2"
|
class="btn btn-sm btn-outline-secondary ms-2"
|
||||||
title="View Contract">
|
title="View Contract">
|
||||||
|
|||||||
@@ -4,59 +4,179 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<style>
|
<style>
|
||||||
.qr-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
|
.qr-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
|
||||||
.qr-item { break-inside: avoid; page-break-inside: avoid; text-align: center; }
|
.qr-item { position: relative; break-inside: avoid; page-break-inside: avoid;
|
||||||
|
text-align: center; cursor: pointer; }
|
||||||
.qr-item img { width: 220px; height: 220px; max-width: 100%; }
|
.qr-item img { width: 220px; height: 220px; max-width: 100%; }
|
||||||
|
.qr-item.qr-selected { outline: 3px solid #2563eb; outline-offset: -1px; }
|
||||||
|
.qr-check { position: absolute; top: 10px; left: 10px; }
|
||||||
|
.qr-check .form-check-input { width: 1.25rem; height: 1.25rem; }
|
||||||
|
.qr-kind { font-size: .68rem; letter-spacing: .08em; }
|
||||||
@media print {
|
@media print {
|
||||||
.no-print { display: none !important; }
|
.no-print { display: none !important; }
|
||||||
.navbar, nav, footer { display: none !important; }
|
.navbar, nav, footer { display: none !important; }
|
||||||
.qr-item { border: 1px dashed #bbb !important; }
|
.qr-item { border: 1px dashed #bbb !important; cursor: default; }
|
||||||
/* Two per row, comfortable for cutting/posting */
|
.qr-item.qr-selected { outline: none !important; }
|
||||||
.qr-grid { gap: 8px; }
|
.qr-grid { gap: 8px; }
|
||||||
|
/* When printing a selection, hide the unselected cards. */
|
||||||
|
body.print-selected-only .qr-item:not(.qr-selected) { display: none !important; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3 no-print">
|
<div class="d-flex justify-content-between align-items-center mb-3 no-print">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="h4 mb-0"><i class="bi bi-qr-code"></i> Facility QR Codes</h2>
|
<h2 class="h4 mb-0"><i class="bi bi-qr-code"></i> QR Codes</h2>
|
||||||
<div class="text-muted small">
|
<div class="text-muted small">
|
||||||
{% if selected_contract %}Contract: {{ selected_contract.name }} — {% endif %}
|
{% if selected_contract %}Contract: {{ selected_contract.name }} — {% endif %}
|
||||||
{{ facilities|length }} facilit{{ 'y' if facilities|length == 1 else 'ies' }}
|
{{ facilities|length }} facilit{{ 'y' if facilities|length == 1 else 'ies' }}
|
||||||
|
{% if include_areas %}(with areas){% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<a href="{{ url_for('facilities.list_facilities') }}" class="btn btn-outline-secondary btn-sm">
|
||||||
<a href="{{ url_for('facilities.list_facilities') }}" class="btn btn-outline-secondary btn-sm">
|
<i class="bi bi-arrow-left"></i> Back
|
||||||
<i class="bi bi-arrow-left"></i> Back
|
</a>
|
||||||
</a>
|
|
||||||
<button onclick="window.print()" class="btn btn-primary btn-sm">
|
|
||||||
<i class="bi bi-printer"></i> Print All
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if facilities %}
|
{# ── Filter bar (GET reload) ─────────────────────────────────────────────── #}
|
||||||
<div class="qr-grid">
|
<form method="get" id="filterForm" class="card card-body mb-3 no-print">
|
||||||
{% for f in facilities %}
|
<div class="row g-2 align-items-end">
|
||||||
<div class="qr-item card shadow-sm p-3">
|
<div class="col-md-4">
|
||||||
<div class="text-muted text-uppercase" style="font-size:.7rem;letter-spacing:.08em;">
|
<label class="form-label small fw-semibold mb-1">Contract</label>
|
||||||
Scan for Facility Status
|
<select name="contract_id" class="form-select form-select-sm"
|
||||||
|
onchange="document.getElementById('facilitySelect').value=''; this.form.submit();">
|
||||||
|
<option value="">All Contracts</option>
|
||||||
|
{% for c in contracts %}
|
||||||
|
<option value="{{ c.id }}" {{ 'selected' if selected_contract_id == c.id }}>{{ c.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="fw-bold">{{ f.name }}</div>
|
<div class="col-md-4">
|
||||||
{% if f.project %}
|
<label class="form-label small fw-semibold mb-1">Facility</label>
|
||||||
<div class="text-muted small mb-1">{{ f.project.name }}</div>
|
<select name="facility_id" id="facilitySelect" class="form-select form-select-sm">
|
||||||
{% endif %}
|
<option value="">All Facilities</option>
|
||||||
<div>
|
{% for f in facility_options %}
|
||||||
<img src="{{ url_for('facilities.facility_qr_png', facility_id=f.id) }}"
|
<option value="{{ f.id }}" {{ 'selected' if selected_facility_id == f.id }}>{{ f.name }}</option>
|
||||||
alt="QR code for {{ f.name }}" loading="lazy">
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="include_areas" value="1"
|
||||||
|
id="includeAreas" {{ 'checked' if include_areas }}>
|
||||||
|
<label class="form-check-label small" for="includeAreas">Include area QR codes</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm w-100">
|
||||||
|
<i class="bi bi-funnel"></i> Apply
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="small">Report a problem & view recent quality</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
</form>
|
||||||
</div>
|
|
||||||
{% else %}
|
{# ── Selection toolbar + export form ─────────────────────────────────────── #}
|
||||||
<div class="alert alert-info">No active facilities to print QR codes for.</div>
|
<form method="post" action="{{ url_for('facilities.facility_qr_export_pdf') }}" id="qrForm">
|
||||||
{% endif %}
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-3 no-print flex-wrap">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="selectAllQr(true)">Select All</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="selectAllQr(false)">Clear</button>
|
||||||
|
<span class="text-muted small" id="selCount">0 selected</span>
|
||||||
|
<div class="ms-auto d-flex gap-2">
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" onclick="printSelected()">
|
||||||
|
<i class="bi bi-printer"></i> Print Selected
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm">
|
||||||
|
<i class="bi bi-file-earmark-pdf"></i> Export Selected to PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if facilities %}
|
||||||
|
<div class="qr-grid">
|
||||||
|
{% for f in facilities %}
|
||||||
|
{# Facility QR card #}
|
||||||
|
<label class="qr-item card shadow-sm p-3 mb-0">
|
||||||
|
<div class="qr-check no-print">
|
||||||
|
<input type="checkbox" class="form-check-input qr-cb" name="facility_ids" value="{{ f.id }}">
|
||||||
|
</div>
|
||||||
|
<div class="text-muted text-uppercase qr-kind">Facility</div>
|
||||||
|
<div class="fw-bold">{{ f.name }}</div>
|
||||||
|
{% if f.project %}
|
||||||
|
<div class="text-muted small mb-1">{{ f.project.name }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<img src="{{ url_for('facilities.facility_qr_png', facility_id=f.id) }}"
|
||||||
|
alt="QR code for {{ f.name }}" loading="lazy">
|
||||||
|
</div>
|
||||||
|
<div class="small">Report a problem & view recent quality</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{% if include_areas %}
|
||||||
|
{% for a in areas_by_facility.get(f.id, []) %}
|
||||||
|
{# Area QR card #}
|
||||||
|
<label class="qr-item card shadow-sm p-3 mb-0">
|
||||||
|
<div class="qr-check no-print">
|
||||||
|
<input type="checkbox" class="form-check-input qr-cb" name="area_ids" value="{{ a.id }}">
|
||||||
|
</div>
|
||||||
|
<div class="text-muted text-uppercase qr-kind">Area</div>
|
||||||
|
<div class="fw-bold">{{ a.name }}</div>
|
||||||
|
<div class="text-muted small mb-1">{{ f.name }}</div>
|
||||||
|
<div>
|
||||||
|
<img src="{{ url_for('facilities.area_qr_png', area_id=a.id) }}"
|
||||||
|
alt="QR code for {{ a.name }}" loading="lazy">
|
||||||
|
</div>
|
||||||
|
<div class="small">Report a problem & view recent quality</div>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info">No facilities match the selected filters.</div>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
|
||||||
<p class="text-muted small mt-3 no-print">
|
<p class="text-muted small mt-3 no-print">
|
||||||
Tip: print, cut along the dashed lines, and post each code at its facility.
|
Tip: tick the codes you want, then <strong>Print Selected</strong> or
|
||||||
|
<strong>Export Selected to PDF</strong>. With nothing ticked, Print Selected prints them all.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function checkboxes() { return document.querySelectorAll('.qr-cb'); }
|
||||||
|
|
||||||
|
function updateCount() {
|
||||||
|
var n = document.querySelectorAll('.qr-cb:checked').length;
|
||||||
|
document.getElementById('selCount').textContent = n + ' selected';
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCard(cb) {
|
||||||
|
var card = cb.closest('.qr-item');
|
||||||
|
if (card) { card.classList.toggle('qr-selected', cb.checked); }
|
||||||
|
}
|
||||||
|
|
||||||
|
window.selectAllQr = function (state) {
|
||||||
|
checkboxes().forEach(function (cb) { cb.checked = state; syncCard(cb); });
|
||||||
|
updateCount();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.printSelected = function () {
|
||||||
|
var anySelected = document.querySelectorAll('.qr-cb:checked').length > 0;
|
||||||
|
if (anySelected) { document.body.classList.add('print-selected-only'); }
|
||||||
|
window.print();
|
||||||
|
setTimeout(function () {
|
||||||
|
document.body.classList.remove('print-selected-only');
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggling a checkbox inside its <label> card also fires on the label click.
|
||||||
|
checkboxes().forEach(function (cb) {
|
||||||
|
cb.addEventListener('change', function () { syncCard(cb); updateCount(); });
|
||||||
|
});
|
||||||
|
|
||||||
|
updateCount();
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -11,13 +11,13 @@
|
|||||||
<a href="{{ url_for('facilities.list_facilities') }}" class="btn btn-outline-secondary">
|
<a href="{{ url_for('facilities.list_facilities') }}" class="btn btn-outline-secondary">
|
||||||
<i class="bi bi-arrow-left"></i> Back to Facilities
|
<i class="bi bi-arrow-left"></i> Back to Facilities
|
||||||
</a>
|
</a>
|
||||||
{% if current_user.role in ['admin', 'director', 'project_manager', 'customer'] %}
|
{% if current_user.role in ['admin', 'director', 'project_manager', 'customer', 'auditor'] %}
|
||||||
<a href="{{ url_for('reports.facility_report', facility_id=facility.id) }}"
|
<a href="{{ url_for('reports.facility_report', facility_id=facility.id) }}"
|
||||||
class="btn btn-outline-info">
|
class="btn btn-outline-info">
|
||||||
<i class="bi bi-graph-up-arrow"></i> Scorecard
|
<i class="bi bi-graph-up-arrow"></i> Scorecard
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.role in ['admin', 'director', 'project_manager', 'customer'] %}
|
{% if current_user.role in ['admin', 'director', 'project_manager', 'customer', 'auditor'] %}
|
||||||
<a href="{{ url_for('facilities.facility_qr_page', facility_id=facility.id) }}"
|
<a href="{{ url_for('facilities.facility_qr_page', facility_id=facility.id) }}"
|
||||||
class="btn btn-outline-dark" title="Printable QR code for this facility">
|
class="btn btn-outline-dark" title="Printable QR code for this facility">
|
||||||
<i class="bi bi-qr-code"></i> QR Code
|
<i class="bi bi-qr-code"></i> QR Code
|
||||||
@@ -135,7 +135,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{{ area.inspections.count() }}</td>
|
<td>{{ area.inspections.count() }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if current_user.role in ['admin', 'director', 'project_manager', 'customer'] %}
|
{% if current_user.role in ['admin', 'director', 'project_manager', 'customer', 'auditor'] %}
|
||||||
<a href="{{ url_for('facilities.area_qr_page', area_id=area.id) }}"
|
<a href="{{ url_for('facilities.area_qr_page', area_id=area.id) }}"
|
||||||
class="btn btn-sm btn-outline-dark" title="Printable QR code for this area">
|
class="btn btn-sm btn-outline-dark" title="Printable QR code for this area">
|
||||||
<i class="bi bi-qr-code"></i>
|
<i class="bi bi-qr-code"></i>
|
||||||
|
|||||||
@@ -52,6 +52,21 @@
|
|||||||
{% for e in form.assigned_to.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
{% for e in form.assigned_to.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Janitorial-staff handler — shown when Handled By = Janitorial Staff #}
|
||||||
|
<div id="internal_handler_block" style="display:none;">
|
||||||
|
<div class="mb-2">
|
||||||
|
{{ form.internal_handler_name.label(class="form-label small fw-semibold mb-1") }}
|
||||||
|
{{ form.internal_handler_name(class="form-control form-control-sm",
|
||||||
|
placeholder="Name of the crew member who will handle it") }}
|
||||||
|
<div class="form-text">Optional — the janitorial staff member doing the work.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form.internal_handler_contact.label(class="form-label small fw-semibold mb-1") }}
|
||||||
|
{{ form.internal_handler_contact(class="form-control form-control-sm",
|
||||||
|
placeholder="Phone or email") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# Facility-staff handler — shown when Handled By = Facility Staff #}
|
{# Facility-staff handler — shown when Handled By = Facility Staff #}
|
||||||
<div id="facility_handler_block" style="display:none;">
|
<div id="facility_handler_block" style="display:none;">
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
@@ -166,8 +181,10 @@
|
|||||||
function syncHandlerUI() {
|
function syncHandlerUI() {
|
||||||
if (!handlerSelect) { return; }
|
if (!handlerSelect) { return; }
|
||||||
var v = handlerSelect.value;
|
var v = handlerSelect.value;
|
||||||
|
var intBlock = document.getElementById('internal_handler_block');
|
||||||
var facBlock = document.getElementById('facility_handler_block');
|
var facBlock = document.getElementById('facility_handler_block');
|
||||||
var venBlock = document.getElementById('vendor_block');
|
var venBlock = document.getElementById('vendor_block');
|
||||||
|
if (intBlock) { intBlock.style.display = (v === 'internal') ? '' : 'none'; }
|
||||||
if (facBlock) { facBlock.style.display = (v === 'facility') ? '' : 'none'; }
|
if (facBlock) { facBlock.style.display = (v === 'facility') ? '' : 'none'; }
|
||||||
if (venBlock) { venBlock.style.display = (v === 'vendor') ? '' : 'none'; }
|
if (venBlock) { venBlock.style.display = (v === 'vendor') ? '' : 'none'; }
|
||||||
var desc = document.getElementById('handler_desc');
|
var desc = document.getElementById('handler_desc');
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h2><i class="bi bi-exclamation-triangle"></i> Issues</h2>
|
<h2><i class="bi bi-exclamation-triangle"></i> Issues</h2>
|
||||||
{% if current_user.role in ['admin','director','customer'] %}
|
{% if current_user.role in ['admin','director','customer','auditor'] %}
|
||||||
<a href="{{ url_for('issues.create') }}" class="btn btn-danger">
|
<a href="{{ url_for('issues.create') }}" class="btn btn-danger">
|
||||||
<i class="bi bi-plus-circle"></i> Log Issue
|
<i class="bi bi-plus-circle"></i> Log Issue
|
||||||
</a>
|
</a>
|
||||||
@@ -167,7 +167,7 @@
|
|||||||
{% else %}<span class="text-muted">—</span>{% endif %}
|
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if current_user.role in ['admin', 'director'] and issue.status != 'resolved' %}
|
{% if current_user.role in ['admin', 'director', 'auditor'] and issue.status != 'resolved' %}
|
||||||
<div class="d-flex align-items-center gap-1 quick-assign-wrap" data-issue-id="{{ issue.id }}">
|
<div class="d-flex align-items-center gap-1 quick-assign-wrap" data-issue-id="{{ issue.id }}">
|
||||||
<select class="form-select form-select-sm quick-assign-select" style="min-width:110px;font-size:.78rem;">
|
<select class="form-select form-select-sm quick-assign-select" style="min-width:110px;font-size:.78rem;">
|
||||||
<option value="">— Unassigned —</option>
|
<option value="">— Unassigned —</option>
|
||||||
@@ -208,7 +208,7 @@
|
|||||||
|
|
||||||
<a href="{{ url_for('issues.view', issue_id=issue.id) }}"
|
<a href="{{ url_for('issues.view', issue_id=issue.id) }}"
|
||||||
class="btn btn-sm btn-outline-secondary">
|
class="btn btn-sm btn-outline-secondary">
|
||||||
{% if current_user.role in ['admin','director'] 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
|
||||||
{% else %}
|
{% else %}
|
||||||
<i class="bi bi-eye"></i> View
|
<i class="bi bi-eye"></i> View
|
||||||
@@ -290,7 +290,7 @@
|
|||||||
}());
|
}());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% if current_user.role in ['admin', 'director'] %}
|
{% if current_user.role in ['admin', 'director', 'auditor'] %}
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% set can_edit = current_user.role in ['admin','director'] or issue.assigned_to == current_user.id %}
|
{% set can_edit = current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %}
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
{# ══════════════════════════════════ LEFT COLUMN ══════════════════════════════════ #}
|
{# ══════════════════════════════════ LEFT COLUMN ══════════════════════════════════ #}
|
||||||
@@ -89,6 +89,17 @@
|
|||||||
</dt>
|
</dt>
|
||||||
<dd class="col-sm-9">{{ issue.assigned_user.display_name if issue.assigned_user else '— Unassigned —' }}</dd>
|
<dd class="col-sm-9">{{ issue.assigned_user.display_name if issue.assigned_user else '— Unassigned —' }}</dd>
|
||||||
|
|
||||||
|
{% if (issue.handler_type or 'internal') == 'internal' and issue.internal_handler_name %}
|
||||||
|
<dt class="col-sm-3">Staff</dt>
|
||||||
|
<dd class="col-sm-9">
|
||||||
|
<i class="bi bi-people text-secondary me-1"></i>
|
||||||
|
<strong>{{ issue.internal_handler_name }}</strong>
|
||||||
|
{% if issue.internal_handler_contact %}
|
||||||
|
<span class="text-muted ms-2">{{ issue.internal_handler_contact }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if issue.handler_type == 'facility' and issue.facility_handler_name %}
|
{% if issue.handler_type == 'facility' and issue.facility_handler_name %}
|
||||||
<dt class="col-sm-3">Facility Contact</dt>
|
<dt class="col-sm-3">Facility Contact</dt>
|
||||||
<dd class="col-sm-9">
|
<dd class="col-sm-9">
|
||||||
@@ -179,7 +190,7 @@
|
|||||||
<div class="alert alert-info py-2 mb-0">
|
<div class="alert alert-info py-2 mb-0">
|
||||||
<i class="bi bi-hourglass-split me-1"></i>
|
<i class="bi bi-hourglass-split me-1"></i>
|
||||||
<strong>Awaiting director verification.</strong>
|
<strong>Awaiting director verification.</strong>
|
||||||
{% if current_user.role in ['admin','director'] %}
|
{% 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="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
@@ -307,10 +318,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% elif current_user.role == 'customer' %}
|
{% elif current_user.role == 'customer' %}
|
||||||
<div class="card-body py-2">
|
<div class="card-body py-2 d-flex align-items-center gap-2 flex-wrap">
|
||||||
<p class="text-muted small mb-0">
|
<p class="text-muted small mb-0">
|
||||||
<i class="bi bi-bell me-1"></i>Follow this issue to add comments.
|
<i class="bi bi-bell me-1"></i>To add comments, click on the Follow button
|
||||||
</p>
|
</p>
|
||||||
|
<form method="post" action="{{ url_for('issues.follow', issue_id=issue.id) }}" class="mb-0">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn btn-outline-primary btn-sm">
|
||||||
|
<i class="bi bi-bell"></i> Follow
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -368,7 +385,7 @@
|
|||||||
{{ form.status.label(class="form-label fw-semibold") }}
|
{{ form.status.label(class="form-label fw-semibold") }}
|
||||||
{{ form.status(class="form-select") }}
|
{{ form.status(class="form-select") }}
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.role in ['admin','director','project_manager'] %}
|
{% if current_user.role in ['admin','director','project_manager','auditor'] %}
|
||||||
{# ── Who handles this issue ── #}
|
{# ── Who handles this issue ── #}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
{{ form.handler_type.label(class="form-label fw-semibold") }}
|
{{ form.handler_type.label(class="form-label fw-semibold") }}
|
||||||
@@ -377,7 +394,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if current_user.role in ['admin','director'] %}
|
{% if current_user.role in ['admin','director','auditor'] %}
|
||||||
<div class="mb-3" id="assigned_to_wrap">
|
<div class="mb-3" id="assigned_to_wrap">
|
||||||
<label class="form-label fw-semibold" id="assigned_to_label">Assign To</label>
|
<label class="form-label fw-semibold" id="assigned_to_label">Assign To</label>
|
||||||
{{ form.assigned_to(class="form-select") }}
|
{{ form.assigned_to(class="form-select") }}
|
||||||
@@ -387,7 +404,27 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if current_user.role in ['admin','director','project_manager'] %}
|
{% if current_user.role in ['admin','director','project_manager','auditor'] %}
|
||||||
|
{# ── Janitorial-staff handler (shown when Handled By = Janitorial Staff) ── #}
|
||||||
|
<div id="internal_handler_block" style="display:none;">
|
||||||
|
<hr class="my-3">
|
||||||
|
<p class="fw-semibold small mb-2">
|
||||||
|
<i class="bi bi-people me-1 text-secondary"></i>Janitorial Staff
|
||||||
|
</p>
|
||||||
|
<div class="mb-2">
|
||||||
|
{{ form.internal_handler_name.label(class="form-label small fw-semibold mb-1") }}
|
||||||
|
{{ form.internal_handler_name(class="form-control form-control-sm",
|
||||||
|
placeholder="Name of the crew member who will handle it",
|
||||||
|
value=issue.internal_handler_name or '') }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form.internal_handler_contact.label(class="form-label small fw-semibold mb-1") }}
|
||||||
|
{{ form.internal_handler_contact(class="form-control form-control-sm",
|
||||||
|
placeholder="Phone or email",
|
||||||
|
value=issue.internal_handler_contact or '') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# ── Facility-staff handler (shown when Handled By = Facility Staff) ── #}
|
{# ── Facility-staff handler (shown when Handled By = Facility Staff) ── #}
|
||||||
<div id="facility_handler_block" style="display:none;">
|
<div id="facility_handler_block" style="display:none;">
|
||||||
<hr class="my-3">
|
<hr class="my-3">
|
||||||
@@ -558,8 +595,10 @@
|
|||||||
function syncHandlerUI() {
|
function syncHandlerUI() {
|
||||||
if (!handlerSelect) { return; }
|
if (!handlerSelect) { return; }
|
||||||
var v = handlerSelect.value;
|
var v = handlerSelect.value;
|
||||||
|
var intBlock = document.getElementById('internal_handler_block');
|
||||||
var facBlock = document.getElementById('facility_handler_block');
|
var facBlock = document.getElementById('facility_handler_block');
|
||||||
var venBlock = document.getElementById('vendor_block');
|
var venBlock = document.getElementById('vendor_block');
|
||||||
|
if (intBlock) { intBlock.style.display = (v === 'internal') ? '' : 'none'; }
|
||||||
if (facBlock) { facBlock.style.display = (v === 'facility') ? '' : 'none'; }
|
if (facBlock) { facBlock.style.display = (v === 'facility') ? '' : 'none'; }
|
||||||
if (venBlock) { venBlock.style.display = (v === 'vendor') ? '' : 'none'; }
|
if (venBlock) { venBlock.style.display = (v === 'vendor') ? '' : 'none'; }
|
||||||
|
|
||||||
|
|||||||
@@ -124,7 +124,7 @@
|
|||||||
Let the cleaning team know.
|
Let the cleaning team know.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form method="POST"
|
<form method="POST" id="reportForm"
|
||||||
action="{{ url_for('public.area_report_problem', token=token) }}"
|
action="{{ url_for('public.area_report_problem', token=token) }}"
|
||||||
enctype="multipart/form-data" novalidate>
|
enctype="multipart/form-data" novalidate>
|
||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
@@ -160,14 +160,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
{{ form.photo.label(class="form-label small fw-semibold") }}
|
{{ form.photos.label(class="form-label small fw-semibold") }}
|
||||||
{{ form.photo(class="form-control", accept="image/*") }}
|
{{ form.photos(class="form-control", accept="image/*", id="reportPhotos", multiple=true) }}
|
||||||
{% for e in form.photo.errors %}
|
<div class="form-text">You can attach up to 5 photos.</div>
|
||||||
|
<div class="text-danger small mt-1" id="photoLimitMsg" style="display:none;">
|
||||||
|
Please select no more than 5 photos — only the first 5 will be used.
|
||||||
|
</div>
|
||||||
|
{% for e in form.photos.errors %}
|
||||||
<div class="text-danger small mt-1">{{ e }}</div>
|
<div class="text-danger small mt-1">{{ e }}</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary w-100">
|
<button type="submit" class="btn btn-primary w-100" id="submitBtn">
|
||||||
<i class="bi bi-send"></i> Submit Report
|
<i class="bi bi-send"></i> Submit Report
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -177,5 +181,45 @@
|
|||||||
Janitorial Quality Control
|
Janitorial Quality Control
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Cap photo selection at 5.
|
||||||
|
var input = document.getElementById('reportPhotos');
|
||||||
|
var msg = document.getElementById('photoLimitMsg');
|
||||||
|
if (input) {
|
||||||
|
input.addEventListener('change', function () {
|
||||||
|
if (input.files && input.files.length > 5) {
|
||||||
|
if (msg) { msg.style.display = 'block'; }
|
||||||
|
input.value = ''; // clear an over-limit selection so they re-pick
|
||||||
|
} else if (msg) {
|
||||||
|
msg.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent duplicate reports: disable the button on first submit so a slow
|
||||||
|
// network can't be double-tapped into multiple identical reports.
|
||||||
|
var form = document.getElementById('reportForm');
|
||||||
|
var btn = document.getElementById('submitBtn');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
if (form.dataset.submitting === '1') { e.preventDefault(); return; }
|
||||||
|
form.dataset.submitting = '1';
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Submitting…';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// After a successful submit we redirect back here with a flash message;
|
||||||
|
// jump to the top so the confirmation is seen and the form isn't re-tapped.
|
||||||
|
if (document.querySelector('.alert')) {
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -123,7 +123,7 @@
|
|||||||
Notice something that needs attention? Let the cleaning team know.
|
Notice something that needs attention? Let the cleaning team know.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form method="POST"
|
<form method="POST" id="reportForm"
|
||||||
action="{{ url_for('public.report_problem', token=token) }}"
|
action="{{ url_for('public.report_problem', token=token) }}"
|
||||||
enctype="multipart/form-data" novalidate>
|
enctype="multipart/form-data" novalidate>
|
||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
@@ -159,14 +159,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
{{ form.photo.label(class="form-label small fw-semibold") }}
|
{{ form.photos.label(class="form-label small fw-semibold") }}
|
||||||
{{ form.photo(class="form-control", accept="image/*") }}
|
{{ form.photos(class="form-control", accept="image/*", id="reportPhotos", multiple=true) }}
|
||||||
{% for e in form.photo.errors %}
|
<div class="form-text">You can attach up to 5 photos.</div>
|
||||||
|
<div class="text-danger small mt-1" id="photoLimitMsg" style="display:none;">
|
||||||
|
Please select no more than 5 photos — only the first 5 will be used.
|
||||||
|
</div>
|
||||||
|
{% for e in form.photos.errors %}
|
||||||
<div class="text-danger small mt-1">{{ e }}</div>
|
<div class="text-danger small mt-1">{{ e }}</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary w-100">
|
<button type="submit" class="btn btn-primary w-100" id="submitBtn">
|
||||||
<i class="bi bi-send"></i> Submit Report
|
<i class="bi bi-send"></i> Submit Report
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -176,5 +180,45 @@
|
|||||||
Janitorial Quality Control
|
Janitorial Quality Control
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Cap photo selection at 5.
|
||||||
|
var input = document.getElementById('reportPhotos');
|
||||||
|
var msg = document.getElementById('photoLimitMsg');
|
||||||
|
if (input) {
|
||||||
|
input.addEventListener('change', function () {
|
||||||
|
if (input.files && input.files.length > 5) {
|
||||||
|
if (msg) { msg.style.display = 'block'; }
|
||||||
|
input.value = ''; // clear an over-limit selection so they re-pick
|
||||||
|
} else if (msg) {
|
||||||
|
msg.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent duplicate reports: disable the button on first submit so a slow
|
||||||
|
// network can't be double-tapped into multiple identical reports.
|
||||||
|
var form = document.getElementById('reportForm');
|
||||||
|
var btn = document.getElementById('submitBtn');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
if (form.dataset.submitting === '1') { e.preventDefault(); return; }
|
||||||
|
form.dataset.submitting = '1';
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Submitting…';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// After a successful submit we redirect back here with a flash message;
|
||||||
|
// jump to the top so the confirmation is seen and the form isn't re-tapped.
|
||||||
|
if (document.querySelector('.alert')) {
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<i class="bi bi-shield-check me-1"></i>SLA Compliance
|
<i class="bi bi-shield-check me-1"></i>SLA Compliance
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
|
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {{ 'active' if request.endpoint == 'reports.followup_closure' else '' }}"
|
<a class="nav-link {{ 'active' if request.endpoint == 'reports.followup_closure' else '' }}"
|
||||||
href="{{ url_for('reports.followup_closure') }}">
|
href="{{ url_for('reports.followup_closure') }}">
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
|
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('scheduled_reports.') else '' }}"
|
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('scheduled_reports.') else '' }}"
|
||||||
href="{{ url_for('scheduled_reports.index') }}">
|
href="{{ url_for('scheduled_reports.index') }}">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<a href="{{ url_for('inspections.index') }}" class="btn btn-outline-secondary">
|
<a href="{{ url_for('inspections.index') }}" class="btn btn-outline-secondary">
|
||||||
<i class="bi bi-arrow-left"></i> Inspections
|
<i class="bi bi-arrow-left"></i> Inspections
|
||||||
</a>
|
</a>
|
||||||
{% if current_user.role in ['admin','director','project_manager'] %}
|
{% if current_user.role in ['admin','director','project_manager','auditor'] %}
|
||||||
<a href="{{ url_for('scheduled_inspections.create') }}" class="btn btn-primary">
|
<a href="{{ url_for('scheduled_inspections.create') }}" class="btn btn-primary">
|
||||||
<i class="bi bi-plus-circle"></i> New Schedule
|
<i class="bi bi-plus-circle"></i> New Schedule
|
||||||
</a>
|
</a>
|
||||||
@@ -60,14 +60,14 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end text-nowrap">
|
<td class="text-end text-nowrap">
|
||||||
{% if s.active and (current_user.role in ['admin','director','project_manager']
|
{% if s.active and (current_user.role in ['admin','director','project_manager','auditor']
|
||||||
or (current_user.role == 'inspector' and s.inspector_id == current_user.id)) %}
|
or (current_user.role == 'inspector' and s.inspector_id == current_user.id)) %}
|
||||||
<a href="{{ url_for('scheduled_inspections.start', schedule_id=s.id) }}"
|
<a href="{{ url_for('scheduled_inspections.start', schedule_id=s.id) }}"
|
||||||
class="btn btn-sm btn-success" title="Start this inspection">
|
class="btn btn-sm btn-success" title="Start this inspection">
|
||||||
<i class="bi bi-play-fill"></i> Start
|
<i class="bi bi-play-fill"></i> Start
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.role in ['admin','director','project_manager'] %}
|
{% if current_user.role in ['admin','director','project_manager','auditor'] %}
|
||||||
<a href="{{ url_for('scheduled_inspections.edit', schedule_id=s.id) }}"
|
<a href="{{ url_for('scheduled_inspections.edit', schedule_id=s.id) }}"
|
||||||
class="btn btn-sm btn-outline-primary"><i class="bi bi-pencil"></i></a>
|
class="btn btn-sm btn-outline-primary"><i class="bi bi-pencil"></i></a>
|
||||||
<form method="POST" class="d-inline"
|
<form method="POST" class="d-inline"
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="p-4 text-muted text-center">
|
<div class="p-4 text-muted text-center">
|
||||||
No scheduled inspections yet.
|
No scheduled inspections yet.
|
||||||
{% if current_user.role in ['admin','director','project_manager'] %}
|
{% if current_user.role in ['admin','director','project_manager','auditor'] %}
|
||||||
<a href="{{ url_for('scheduled_inspections.create') }}">Create one</a>.
|
<a href="{{ url_for('scheduled_inspections.create') }}">Create one</a>.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+24
-2
@@ -54,17 +54,39 @@ def supervisor_required(f):
|
|||||||
return decorated_function
|
return decorated_function
|
||||||
|
|
||||||
def project_manager_required(f):
|
def project_manager_required(f):
|
||||||
"""Grants access to admin, director, and project_manager roles."""
|
"""Grants access to admin, director, project_manager, and auditor roles.
|
||||||
|
|
||||||
|
Auditor mirrors Project Manager for all baseline access, so it is included
|
||||||
|
here alongside project_manager.
|
||||||
|
"""
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated_function(*args, **kwargs):
|
def decorated_function(*args, **kwargs):
|
||||||
if not current_user.is_authenticated or current_user.role not in [
|
if not current_user.is_authenticated or current_user.role not in [
|
||||||
'admin', 'director', 'project_manager'
|
'admin', 'director', 'project_manager', 'auditor'
|
||||||
]:
|
]:
|
||||||
flash('Project Manager access required.', 'danger')
|
flash('Project Manager access required.', 'danger')
|
||||||
return redirect(url_for('dashboard.index'))
|
return redirect(url_for('dashboard.index'))
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
return decorated_function
|
return decorated_function
|
||||||
|
|
||||||
|
def issue_manager_required(f):
|
||||||
|
"""Grants access to admin, director, and auditor roles.
|
||||||
|
|
||||||
|
Used for issue-management powers that go beyond the Project Manager
|
||||||
|
baseline (verification and the verification queue). Deliberately does NOT
|
||||||
|
include project_manager, and does NOT grant issue deletion — delete stays
|
||||||
|
on @supervisor_required (admin/director only).
|
||||||
|
"""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if not current_user.is_authenticated or current_user.role not in [
|
||||||
|
'admin', 'director', 'auditor'
|
||||||
|
]:
|
||||||
|
flash('Issue management access required.', 'danger')
|
||||||
|
return redirect(url_for('dashboard.index'))
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
def customer_required(f):
|
def customer_required(f):
|
||||||
"""Restricts access to customer-role users only.
|
"""Restricts access to customer-role users only.
|
||||||
|
|
||||||
|
|||||||
+11
-4
@@ -55,6 +55,7 @@ class UserForm(FlaskForm):
|
|||||||
('director', 'Director'),
|
('director', 'Director'),
|
||||||
('inspector', 'Inspector'),
|
('inspector', 'Inspector'),
|
||||||
('project_manager', 'Project Manager'),
|
('project_manager', 'Project Manager'),
|
||||||
|
('auditor', 'Auditor'),
|
||||||
# 'customer' is intentionally excluded — customer accounts are managed via /customers
|
# 'customer' is intentionally excluded — customer accounts are managed via /customers
|
||||||
], validators=[Optional()])
|
], validators=[Optional()])
|
||||||
# NOTE: Optional() here because directors submit no role value (the field is
|
# NOTE: Optional() here because directors submit no role value (the field is
|
||||||
@@ -183,6 +184,9 @@ class IssueForm(FlaskForm):
|
|||||||
vendor_name = StringField('Contractor Name', validators=[Optional(), Length(max=100)])
|
vendor_name = StringField('Contractor Name', validators=[Optional(), Length(max=100)])
|
||||||
vendor_contact = StringField('Contractor Contact', validators=[Optional(), Length(max=200)])
|
vendor_contact = StringField('Contractor Contact', validators=[Optional(), Length(max=200)])
|
||||||
vendor_notes = TextAreaField('Contractor Notes', validators=[Optional(), Length(max=1000)])
|
vendor_notes = TextAreaField('Contractor Notes', validators=[Optional(), Length(max=1000)])
|
||||||
|
# Janitorial staff member's name + contact — used when handler_type == 'internal'
|
||||||
|
internal_handler_name = StringField('Staff Name', validators=[Optional(), Length(max=100)])
|
||||||
|
internal_handler_contact = StringField('Staff Contact', validators=[Optional(), Length(max=200)])
|
||||||
|
|
||||||
|
|
||||||
class IssueUpdateForm(FlaskForm):
|
class IssueUpdateForm(FlaskForm):
|
||||||
@@ -211,6 +215,9 @@ class IssueUpdateForm(FlaskForm):
|
|||||||
vendor_name = StringField('Contractor Name', validators=[Optional(), Length(max=100)])
|
vendor_name = StringField('Contractor Name', validators=[Optional(), Length(max=100)])
|
||||||
vendor_contact = StringField('Contractor Contact', validators=[Optional(), Length(max=200)])
|
vendor_contact = StringField('Contractor Contact', validators=[Optional(), Length(max=200)])
|
||||||
vendor_notes = TextAreaField('Contractor Notes', validators=[Optional(), Length(max=1000)])
|
vendor_notes = TextAreaField('Contractor Notes', validators=[Optional(), Length(max=1000)])
|
||||||
|
# Janitorial staff member's name + contact — used when handler_type == 'internal'
|
||||||
|
internal_handler_name = StringField('Staff Name', validators=[Optional(), Length(max=100)])
|
||||||
|
internal_handler_contact = StringField('Staff Contact', validators=[Optional(), Length(max=200)])
|
||||||
|
|
||||||
# ── Projects ─────────────────────────────────────────────────────────────────
|
# ── Projects ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -314,10 +321,10 @@ class PublicIssueReportForm(FlaskForm):
|
|||||||
validators=[Optional(), Length(max=100)])
|
validators=[Optional(), Length(max=100)])
|
||||||
reporter_contact = StringField('Email or phone (optional)',
|
reporter_contact = StringField('Email or phone (optional)',
|
||||||
validators=[Optional(), Length(max=120)])
|
validators=[Optional(), Length(max=120)])
|
||||||
photo = FileField('Add a photo (optional)',
|
photos = MultipleFileField('Add photos (optional, up to 5)',
|
||||||
validators=[Optional(),
|
validators=[Optional(),
|
||||||
FileAllowed(['jpg', 'jpeg', 'png', 'gif'],
|
FileAllowed(['jpg', 'jpeg', 'png', 'gif'],
|
||||||
'Images only (jpg, png, gif).')])
|
'Images only (jpg, png, gif).')])
|
||||||
website = StringField('Website') # honeypot — must stay empty
|
website = StringField('Website') # honeypot — must stay empty
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -546,6 +546,7 @@ def notify_by_matrix(
|
|||||||
'director': 'director',
|
'director': 'director',
|
||||||
'inspector': 'inspector',
|
'inspector': 'inspector',
|
||||||
'project_manager': 'project_manager',
|
'project_manager': 'project_manager',
|
||||||
|
'auditor': 'auditor',
|
||||||
'customer': 'customer',
|
'customer': 'customer',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,6 +569,20 @@ def notify_by_matrix(
|
|||||||
logger.info('MATRIX NOTIFY | event=%s | role=%s | users_found=%s',
|
logger.info('MATRIX NOTIFY | event=%s | role=%s | users_found=%s',
|
||||||
event_type, role_key, [u.username for u in users])
|
event_type, role_key, [u.username for u in users])
|
||||||
|
|
||||||
|
# Scope the inspector role for "inspection_completed" to the inspection's
|
||||||
|
# OWN inspector (the person who did the work), not the whole inspector
|
||||||
|
# pool. Without this, enabling the Inspector column for this event would
|
||||||
|
# notify every inspector on every submission.
|
||||||
|
if role_key == 'inspector' and event_type == 'inspection_completed':
|
||||||
|
target_id = None
|
||||||
|
if inspection_id:
|
||||||
|
from app.models.inspection import Inspection
|
||||||
|
insp = db.session.get(Inspection, inspection_id)
|
||||||
|
target_id = insp.inspector_id if insp else None
|
||||||
|
users = [u for u in users if u.id == target_id] if target_id else []
|
||||||
|
logger.info('MATRIX NOTIFY | event=%s | role=inspector scoped to '
|
||||||
|
'submitting inspector_id=%s', event_type, target_id)
|
||||||
|
|
||||||
# Scope customer role to facility if provided
|
# Scope customer role to facility if provided
|
||||||
if role_key == 'customer' and facility_id:
|
if role_key == 'customer' and facility_id:
|
||||||
from app.utils.notifications import notify_customers_for_facility
|
from app.utils.notifications import notify_customers_for_facility
|
||||||
|
|||||||
@@ -1589,4 +1589,94 @@ def generate_facility_summary_pdf(facility, days, start, now,
|
|||||||
))
|
))
|
||||||
doc.build(story)
|
doc.build(story)
|
||||||
return buf.getvalue()
|
return buf.getvalue()
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_qr_codes_pdf(items, filter_summary: str = '') -> bytes:
|
||||||
|
"""Return a PDF byte-string laying out selected QR codes in a grid.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
items : list of dicts, each:
|
||||||
|
{
|
||||||
|
'title': str, # main label (facility or area name)
|
||||||
|
'subtitle': str | None, # e.g. contract name, or parent facility
|
||||||
|
'caption': str | None, # small line under the QR
|
||||||
|
'png': bytes, # QR code PNG image bytes
|
||||||
|
}
|
||||||
|
filter_summary : human-readable string describing the selection (optional)
|
||||||
|
"""
|
||||||
|
buf = io.BytesIO()
|
||||||
|
generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET')
|
||||||
|
report_title = 'QR Codes'
|
||||||
|
|
||||||
|
doc = SimpleDocTemplate(
|
||||||
|
buf,
|
||||||
|
pagesize=letter,
|
||||||
|
leftMargin=0.65 * inch,
|
||||||
|
rightMargin=0.65 * inch,
|
||||||
|
topMargin=1.35 * inch,
|
||||||
|
bottomMargin=0.75 * inch,
|
||||||
|
title=report_title,
|
||||||
|
author='Janitorial QC System',
|
||||||
|
)
|
||||||
|
|
||||||
|
def _page_cb(canvas, doc):
|
||||||
|
_on_page(canvas, doc, report_title, generated_at)
|
||||||
|
|
||||||
|
story = []
|
||||||
|
if filter_summary:
|
||||||
|
story.append(Paragraph(f'Filters: {filter_summary}', STYLES['ReportSub']))
|
||||||
|
story.append(Paragraph(
|
||||||
|
f'Total codes: {len(items)}', STYLES['ReportSub']))
|
||||||
|
story.append(Spacer(1, 10))
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
story.append(Paragraph('No QR codes selected.', STYLES['FieldValue']))
|
||||||
|
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
COLS = 3
|
||||||
|
pw = letter[0] - 1.3 * inch # usable width
|
||||||
|
cell_w = pw / COLS
|
||||||
|
qr_size = 1.7 * inch
|
||||||
|
|
||||||
|
title_style = ParagraphStyle('QRT', fontName='Helvetica-Bold', fontSize=9,
|
||||||
|
alignment=TA_CENTER, leading=11, textColor=C_DARK)
|
||||||
|
sub_style = ParagraphStyle('QRS', fontName='Helvetica', fontSize=7.5,
|
||||||
|
alignment=TA_CENTER, leading=9, textColor=C_SLATE)
|
||||||
|
cap_style = ParagraphStyle('QRC', fontName='Helvetica', fontSize=6.5,
|
||||||
|
alignment=TA_CENTER, leading=8, textColor=C_SLATE)
|
||||||
|
|
||||||
|
def _cell(item):
|
||||||
|
flow = [Paragraph(item.get('title') or '', title_style)]
|
||||||
|
if item.get('subtitle'):
|
||||||
|
flow.append(Paragraph(item['subtitle'], sub_style))
|
||||||
|
flow.append(Spacer(1, 4))
|
||||||
|
flow.append(RLImage(io.BytesIO(item['png']), width=qr_size, height=qr_size))
|
||||||
|
if item.get('caption'):
|
||||||
|
flow.append(Spacer(1, 3))
|
||||||
|
flow.append(Paragraph(item['caption'], cap_style))
|
||||||
|
return flow
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for i in range(0, len(items), COLS):
|
||||||
|
chunk = items[i:i + COLS]
|
||||||
|
row = [_cell(it) for it in chunk]
|
||||||
|
while len(row) < COLS:
|
||||||
|
row.append('') # filler cell to keep the grid rectangular
|
||||||
|
rows.append(row)
|
||||||
|
|
||||||
|
tbl = Table(rows, colWidths=[cell_w] * COLS)
|
||||||
|
tbl.setStyle(TableStyle([
|
||||||
|
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||||
|
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
||||||
|
('TOPPADDING', (0, 0), (-1, -1), 10),
|
||||||
|
('BOTTOMPADDING', (0, 0), (-1, -1), 16),
|
||||||
|
('LEFTPADDING', (0, 0), (-1, -1), 6),
|
||||||
|
('RIGHTPADDING', (0, 0), (-1, -1), 6),
|
||||||
|
]))
|
||||||
|
story.append(tbl)
|
||||||
|
|
||||||
|
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
|
||||||
return buf.getvalue()
|
return buf.getvalue()
|
||||||
Binary file not shown.
@@ -0,0 +1,45 @@
|
|||||||
|
"""phase40 — add 'auditor' role to users.role ENUM
|
||||||
|
|
||||||
|
Introduces a new staff role, Auditor, with the same access as Project Manager
|
||||||
|
plus full issue-management powers (assign, verify, quick-assign, handler triage,
|
||||||
|
create) — but NOT issue deletion (that stays admin/director).
|
||||||
|
|
||||||
|
This is a pure ENUM expansion (adds a value, no data migration, no value
|
||||||
|
removal), so the 3-step ENUM protocol does not apply. Re-running the same
|
||||||
|
MODIFY is a no-op — safe to re-run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
revision = 'phase40_auditor_role'
|
||||||
|
down_revision = 'phase39_area_public_token'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
_ENUM_WITH_AUDITOR = (
|
||||||
|
"ENUM('admin','director','inspector','project_manager','customer','auditor')"
|
||||||
|
)
|
||||||
|
_ENUM_WITHOUT_AUDITOR = (
|
||||||
|
"ENUM('admin','director','inspector','project_manager','customer')"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
# Idempotent: MODIFY to the expanded set is harmless if already applied.
|
||||||
|
op.execute(sa.text(
|
||||||
|
f"ALTER TABLE users MODIFY COLUMN role {_ENUM_WITH_AUDITOR} NOT NULL"
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
# Reassign any auditor rows before contracting the ENUM so no data is lost.
|
||||||
|
op.execute(sa.text(
|
||||||
|
"UPDATE users SET role = 'project_manager' WHERE role = 'auditor'"
|
||||||
|
))
|
||||||
|
op.execute(sa.text(
|
||||||
|
f"ALTER TABLE users MODIFY COLUMN role {_ENUM_WITHOUT_AUDITOR} NOT NULL"
|
||||||
|
))
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""phase41 — issue internal_handler_name
|
||||||
|
|
||||||
|
Adds a free-text `internal_handler_name` to `issues`, capturing the name of the
|
||||||
|
janitorial staff member who will handle an issue when handler_type == 'internal'.
|
||||||
|
Distinct from `assigned_to` (the JQC User who owns follow-up) — the actual crew
|
||||||
|
member may not be a system user.
|
||||||
|
|
||||||
|
Uses an INFORMATION_SCHEMA column-existence check — safe to re-run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
revision = 'phase41_internal_handler'
|
||||||
|
down_revision = 'phase40_auditor_role'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(conn, table, column):
|
||||||
|
result = conn.execute(sa.text(
|
||||||
|
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
|
||||||
|
"WHERE TABLE_SCHEMA = DATABASE() "
|
||||||
|
"AND TABLE_NAME = :t AND COLUMN_NAME = :c"
|
||||||
|
), {"t": table, "c": column})
|
||||||
|
return result.scalar() > 0
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
if not _column_exists(bind, 'issues', 'internal_handler_name'):
|
||||||
|
op.execute(sa.text(
|
||||||
|
"ALTER TABLE issues ADD COLUMN internal_handler_name VARCHAR(100) NULL"
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
if _column_exists(bind, 'issues', 'internal_handler_name'):
|
||||||
|
op.execute(sa.text("ALTER TABLE issues DROP COLUMN internal_handler_name"))
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""phase42 — issue internal_handler_contact
|
||||||
|
|
||||||
|
Adds `internal_handler_contact` (phone or email) to `issues`, the contact for the
|
||||||
|
janitorial staff member who will handle an issue when handler_type == 'internal'.
|
||||||
|
Parallels `internal_handler_name` (phase41) and the facility/vendor contact fields.
|
||||||
|
|
||||||
|
Uses an INFORMATION_SCHEMA column-existence check — safe to re-run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
revision = 'phase42_internal_contact'
|
||||||
|
down_revision = 'phase41_internal_handler'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(conn, table, column):
|
||||||
|
result = conn.execute(sa.text(
|
||||||
|
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
|
||||||
|
"WHERE TABLE_SCHEMA = DATABASE() "
|
||||||
|
"AND TABLE_NAME = :t AND COLUMN_NAME = :c"
|
||||||
|
), {"t": table, "c": column})
|
||||||
|
return result.scalar() > 0
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
if not _column_exists(bind, 'issues', 'internal_handler_contact'):
|
||||||
|
op.execute(sa.text(
|
||||||
|
"ALTER TABLE issues ADD COLUMN internal_handler_contact VARCHAR(200) NULL"
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
if _column_exists(bind, 'issues', 'internal_handler_contact'):
|
||||||
|
op.execute(sa.text("ALTER TABLE issues DROP COLUMN internal_handler_contact"))
|
||||||
Reference in New Issue
Block a user