Compare commits
10
Commits
| 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.
|
||||
> **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
|
||||
```
|
||||
|
||||
**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`.
|
||||
|
||||
@@ -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
|
||||
facility_handler_name VARCHAR(100) 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:**
|
||||
|
||||
| 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** |
|
||||
| `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.
|
||||
|
||||
`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`).
|
||||
- **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 |
|
||||
|---|---|---|---|
|
||||
| `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" |
|
||||
|
||||
**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
|
||||
|
||||
**`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 |
|
||||
|---|---|---|---|---|---|
|
||||
| Dashboard | ✅ full | ✅ full | ✅ full | ✅ limited | ✅ scoped |
|
||||
@@ -454,7 +462,8 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi
|
||||
```python
|
||||
@admin_required # role == 'admin' only
|
||||
@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
|
||||
```
|
||||
|
||||
@@ -466,13 +475,13 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi
|
||||
|---|---|---|
|
||||
| `auth` | `/auth` | `/login`, `/logout`, `/profile`, `/users/*`, `/notification-matrix` |
|
||||
| `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). |
|
||||
| `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. |
|
||||
| `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). 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) |
|
||||
| `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) |
|
||||
| `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) |
|
||||
| `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 |
|
||||
@@ -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_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_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`
|
||||
|
||||
@@ -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)
|
||||
'facility_handler_name', 'facility_handler_contact', 'facility_handler_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
|
||||
```
|
||||
|
||||
### 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)
|
||||
|
||||
`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
|
||||
→ phase37_support_chat
|
||||
→ 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
|
||||
@@ -955,6 +973,36 @@ flask db upgrade # adds + backfills areas.public_token
|
||||
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:**
|
||||
```bash
|
||||
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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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`). |
|
||||
| 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__)
|
||||
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||
|
||||
|
||||
def _comment_payload(comment: IssueComment) -> dict:
|
||||
|
||||
@@ -34,7 +34,7 @@ logger = logging.getLogger(__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:
|
||||
|
||||
+7
-2
@@ -41,7 +41,7 @@ logger = logging.getLogger(__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_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'}
|
||||
_VALID_HANDLERS = {'internal', 'facility', 'vendor'}
|
||||
@@ -109,6 +109,8 @@ def _issue_payload(issue):
|
||||
'vendor_name': issue.vendor_name or None,
|
||||
'vendor_contact': issue.vendor_contact 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
|
||||
"vendor_name": "...", // optional (vendor handler)
|
||||
"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.
|
||||
@@ -463,6 +467,7 @@ def update_issue_handler(issue_id):
|
||||
_text_fields = (
|
||||
'facility_handler_name', 'facility_handler_contact', 'facility_handler_notes',
|
||||
'vendor_name', 'vendor_contact', 'vendor_notes',
|
||||
'internal_handler_name', 'internal_handler_contact',
|
||||
)
|
||||
for field in _text_fields:
|
||||
if field in data:
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
|
||||
bp = Blueprint('api_photos', __name__)
|
||||
|
||||
_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:
|
||||
|
||||
@@ -28,7 +28,7 @@ logger = logging.getLogger(__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):
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ logger = logging.getLogger(__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'])
|
||||
|
||||
@@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
|
||||
bp = Blueprint('api_templates', __name__)
|
||||
|
||||
# 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:
|
||||
|
||||
@@ -95,6 +95,11 @@ class Issue(db.Model):
|
||||
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_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
|
||||
# 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'
|
||||
director — all users with role='director'
|
||||
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'
|
||||
customer — all customer-portal users assigned to the relevant facility
|
||||
assignee — the specific user the issue/inspection is assigned to
|
||||
@@ -40,6 +44,7 @@ MATRIX_ROLES = [
|
||||
('director', 'Director'),
|
||||
('inspector', 'Inspector'),
|
||||
('project_manager', 'Project Manager'),
|
||||
('auditor', 'Auditor'),
|
||||
('customer', 'Customer'),
|
||||
('custom', 'Custom Recipients'),
|
||||
]
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ class User(UserMixin, db.Model):
|
||||
role = db.Column(
|
||||
# Phase 11 migration complete — 'supervisor' removed from both the DB
|
||||
# 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
|
||||
)
|
||||
created_at = db.Column(db.DateTime, default=now_eastern)
|
||||
|
||||
+12
-6
@@ -34,11 +34,14 @@ def index():
|
||||
now = now_eastern()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
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_privileged = current_user.role in ['admin', 'director']
|
||||
is_customer = current_user.role == 'customer'
|
||||
is_project_manager = current_user.role == 'project_manager'
|
||||
is_auditor = current_user.role == 'auditor'
|
||||
|
||||
# Resolve facility scope
|
||||
customer_facility_ids = get_customer_scope(current_user) # None for non-customers
|
||||
@@ -60,14 +63,16 @@ def index():
|
||||
else:
|
||||
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_end,
|
||||
).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.inspection_date >= today_start,
|
||||
Inspection.inspection_date >= week_start,
|
||||
Inspection.inspection_date < today_end,
|
||||
).count()
|
||||
|
||||
@@ -309,9 +314,9 @@ def index():
|
||||
unassigned_open = len(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 = []
|
||||
if is_privileged or is_project_manager:
|
||||
if is_privileged or is_project_manager or is_auditor:
|
||||
active_inspectors = (
|
||||
User.query
|
||||
.filter_by(role='inspector', active=True)
|
||||
@@ -374,7 +379,7 @@ def index():
|
||||
'dashboard.html',
|
||||
sched_upcoming = sched_upcoming,
|
||||
sched_overdue_count = sched_overdue_count,
|
||||
today_inspections = today_inspections,
|
||||
submitted_this_week = submitted_this_week,
|
||||
completed_today = completed_today,
|
||||
open_issues = open_issues,
|
||||
severity_breakdown = severity_breakdown,
|
||||
@@ -397,6 +402,7 @@ def index():
|
||||
customer_facilities = customer_facilities,
|
||||
my_issues = my_issues,
|
||||
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.utils.forms import FacilityForm, AreaForm
|
||||
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
|
||||
|
||||
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))
|
||||
|
||||
|
||||
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')
|
||||
@login_required
|
||||
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
|
||||
no QR management (403); customers are scoped to their assigned facilities;
|
||||
managers see all active facilities.
|
||||
Query params (all optional):
|
||||
?contract_id=<id> — limit to one contract; narrows the facility dropdown
|
||||
?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.
|
||||
if current_user.role == 'inspector':
|
||||
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':
|
||||
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:
|
||||
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:
|
||||
query = query.filter(Facility.project_id == contract_id)
|
||||
# Contract dropdown — only contracts present among the scoped facilities.
|
||||
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
|
||||
for f in facilities:
|
||||
areas_by_facility = {}
|
||||
for f in grid_facilities:
|
||||
if not f.public_token:
|
||||
f.ensure_public_token()
|
||||
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:
|
||||
db.session.commit()
|
||||
|
||||
selected_contract = db.session.get(Project, contract_id) if contract_id else None
|
||||
return render_template('facilities/qr_print_all.html',
|
||||
facilities=facilities,
|
||||
selected_contract=selected_contract)
|
||||
facilities=grid_facilities,
|
||||
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 ───────────────────────────────────────────────────────
|
||||
# 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))
|
||||
|
||||
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,
|
||||
).order_by(User.full_name, User.username).all()
|
||||
|
||||
|
||||
+26
-14
@@ -15,7 +15,7 @@ from app.models.notification import (
|
||||
EVENT_CUSTOMER_ISSUE_UPDATED,
|
||||
)
|
||||
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.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT
|
||||
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 = 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()
|
||||
|
||||
# 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))
|
||||
|
||||
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.status.data = form.status.data or issue.status
|
||||
|
||||
@@ -429,7 +436,7 @@ def view(issue_id):
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
# Handler assignment (who handles it) + vendor/facility details —
|
||||
# admin, director, project_manager only.
|
||||
if current_user.role in ('admin', 'director', 'project_manager'):
|
||||
# admin, director, project_manager, auditor only.
|
||||
if current_user.role in ('admin', 'director', 'project_manager', 'auditor'):
|
||||
handler = form.handler_type.data or 'internal'
|
||||
if handler not in ('internal', 'facility', 'vendor'):
|
||||
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_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
|
||||
new_photos = []
|
||||
for file_obj in request.files.getlist('result_photos'):
|
||||
@@ -690,7 +700,7 @@ def unfollow(issue_id):
|
||||
@bp.route('/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def create():
|
||||
if current_user.role not in ('admin', 'director', 'customer'):
|
||||
if current_user.role not in ('admin', 'director', 'customer', 'auditor'):
|
||||
abort(403)
|
||||
|
||||
from app.models.project import Project, CustomerAssignment
|
||||
@@ -712,7 +722,7 @@ def create():
|
||||
else:
|
||||
facilities = Facility.query.filter_by(active=True).order_by(Facility.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.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_contact = (form.vendor_contact.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.commit()
|
||||
@@ -812,7 +824,7 @@ def create():
|
||||
|
||||
@bp.route('/<int:issue_id>/verify', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
@issue_manager_required
|
||||
def verify(issue_id):
|
||||
"""Supervisor sign-off: confirms resolution is satisfactory and closes the issue."""
|
||||
issue = db.session.get(Issue, issue_id)
|
||||
@@ -846,7 +858,7 @@ def verify(issue_id):
|
||||
|
||||
@bp.route('/bulk-verify', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
@issue_manager_required
|
||||
def bulk_verify():
|
||||
"""Verify multiple pending-verification issues in a single action."""
|
||||
issue_ids = request.form.getlist('issue_ids', type=int)
|
||||
@@ -891,9 +903,9 @@ def request_verification(issue_id):
|
||||
flash('Access denied.', 'danger')
|
||||
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 = (
|
||||
current_user.role in ['admin', 'director']
|
||||
current_user.role in ['admin', 'director', 'auditor']
|
||||
or issue.assigned_to == current_user.id
|
||||
)
|
||||
if not can_act:
|
||||
@@ -936,7 +948,7 @@ def request_verification(issue_id):
|
||||
|
||||
@bp.route('/verification-queue')
|
||||
@login_required
|
||||
@supervisor_required
|
||||
@issue_manager_required
|
||||
def verification_queue():
|
||||
"""Supervisor queue of all issues awaiting verification, grouped by facility."""
|
||||
from app.models.facility import Facility, Area
|
||||
@@ -1032,7 +1044,7 @@ def delete(issue_id):
|
||||
@login_required
|
||||
def quick_assign(issue_id):
|
||||
"""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
|
||||
|
||||
issue = db.session.get(Issue, issue_id)
|
||||
|
||||
+82
-11
@@ -38,6 +38,55 @@ logger = logging.getLogger(__name__)
|
||||
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:
|
||||
"""Resolve an ACTIVE facility from its public token, else 404."""
|
||||
if not token:
|
||||
@@ -320,10 +369,6 @@ def report_problem(token):
|
||||
return render_template('public/facility.html',
|
||||
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
|
||||
# public reporter is not a User, so reported_by stays NULL.
|
||||
parts = ['[Reported via facility QR code]']
|
||||
@@ -336,12 +381,26 @@ def report_problem(token):
|
||||
parts.append(form.description.data.strip())
|
||||
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(
|
||||
facility_id = facility.id,
|
||||
area_id = None,
|
||||
severity = 'medium',
|
||||
description = description,
|
||||
photo_path = photo_path,
|
||||
mobile_photo_paths = extra_photos,
|
||||
status = 'open',
|
||||
reported_at = now_eastern(),
|
||||
reported_by = None,
|
||||
@@ -349,8 +408,9 @@ def report_problem(token):
|
||||
db.session.add(issue)
|
||||
db.session.commit()
|
||||
|
||||
logger.info('PUBLIC REPORT | issue_id=%s | facility_id=%s | ip=%s | photo=%s',
|
||||
issue.id, facility.id, request.remote_addr, bool(photo_path))
|
||||
_photo_count = (1 if photo_path else 0) + (len(extra_photos) if extra_photos else 0)
|
||||
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).
|
||||
notify_by_matrix(
|
||||
@@ -390,9 +450,6 @@ def area_report_problem(token):
|
||||
return render_template('public/area.html',
|
||||
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 source. A public reporter is not a User, so reported_by stays NULL.
|
||||
parts = [f'[Reported via area QR code — {area.name}]']
|
||||
@@ -405,12 +462,25 @@ def area_report_problem(token):
|
||||
parts.append(form.description.data.strip())
|
||||
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(
|
||||
facility_id = facility.id,
|
||||
area_id = area.id,
|
||||
severity = 'medium',
|
||||
description = description,
|
||||
photo_path = photo_path,
|
||||
mobile_photo_paths = extra_photos,
|
||||
status = 'open',
|
||||
reported_at = now_eastern(),
|
||||
reported_by = None,
|
||||
@@ -418,8 +488,9 @@ def area_report_problem(token):
|
||||
db.session.add(issue)
|
||||
db.session.commit()
|
||||
|
||||
logger.info('PUBLIC REPORT | issue_id=%s | area_id=%s | facility_id=%s | ip=%s | photo=%s',
|
||||
issue.id, area.id, facility.id, request.remote_addr, bool(photo_path))
|
||||
_photo_count = (1 if photo_path else 0) + (len(extra_photos) if extra_photos else 0)
|
||||
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(
|
||||
event_type = 'issue_created',
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
{# Row 1: spanning group headers #}
|
||||
<tr>
|
||||
<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">Custom Recipients</th>
|
||||
</tr>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<td>{{ user.full_name or '—' }}</td>
|
||||
<td>{{ user.email }}</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 }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
<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>
|
||||
</li>
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}" href="{{ url_for('projects.index') }}">Contracts</a>
|
||||
</li>
|
||||
@@ -138,7 +138,7 @@
|
||||
<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>
|
||||
</li>
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
{% if current_user.role in ['admin', 'director', 'auditor'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link d-flex align-items-center gap-1 {{ 'active' if request.endpoint == 'issues.verification_queue' }}"
|
||||
href="{{ url_for('issues.verification_queue') }}">
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<div class="row mb-3 align-items-center">
|
||||
<div class="col">
|
||||
<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 }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -58,7 +58,7 @@
|
||||
<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="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) %}
|
||||
<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>
|
||||
@@ -84,21 +84,6 @@
|
||||
</div>
|
||||
<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">
|
||||
<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">
|
||||
@@ -114,6 +99,21 @@
|
||||
</a>
|
||||
</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' %}
|
||||
<div class="col-6 col-md">
|
||||
<a href="{{ url_for('inspections.index', status='in_progress') }}" class="text-decoration-none">
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
{% endif %}
|
||||
</button>
|
||||
<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) }}"
|
||||
class="btn btn-sm btn-outline-secondary ms-2"
|
||||
title="View Contract">
|
||||
|
||||
@@ -4,59 +4,179 @@
|
||||
{% block content %}
|
||||
<style>
|
||||
.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.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 {
|
||||
.no-print { display: none !important; }
|
||||
.navbar, nav, footer { display: none !important; }
|
||||
.qr-item { border: 1px dashed #bbb !important; }
|
||||
/* Two per row, comfortable for cutting/posting */
|
||||
.qr-item { border: 1px dashed #bbb !important; cursor: default; }
|
||||
.qr-item.qr-selected { outline: none !important; }
|
||||
.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>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 no-print">
|
||||
<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">
|
||||
{% if selected_contract %}Contract: {{ selected_contract.name }} — {% endif %}
|
||||
{{ facilities|length }} facilit{{ 'y' if facilities|length == 1 else 'ies' }}
|
||||
{% if include_areas %}(with areas){% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('facilities.list_facilities') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left"></i> Back
|
||||
</a>
|
||||
<button onclick="window.print()" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-printer"></i> Print All
|
||||
</button>
|
||||
</div>
|
||||
<a href="{{ url_for('facilities.list_facilities') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left"></i> Back
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if facilities %}
|
||||
<div class="qr-grid">
|
||||
{% for f in facilities %}
|
||||
<div class="qr-item card shadow-sm p-3">
|
||||
<div class="text-muted text-uppercase" style="font-size:.7rem;letter-spacing:.08em;">
|
||||
Scan for Facility Status
|
||||
{# ── Filter bar (GET reload) ─────────────────────────────────────────────── #}
|
||||
<form method="get" id="filterForm" class="card card-body mb-3 no-print">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-semibold mb-1">Contract</label>
|
||||
<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 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 class="col-md-4">
|
||||
<label class="form-label small fw-semibold mb-1">Facility</label>
|
||||
<select name="facility_id" id="facilitySelect" class="form-select form-select-sm">
|
||||
<option value="">All Facilities</option>
|
||||
{% for f in facility_options %}
|
||||
<option value="{{ f.id }}" {{ 'selected' if selected_facility_id == f.id }}>{{ f.name }}</option>
|
||||
{% 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 class="small">Report a problem & view recent quality</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info">No active facilities to print QR codes for.</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
{# ── Selection toolbar + export form ─────────────────────────────────────── #}
|
||||
<form method="post" action="{{ url_for('facilities.facility_qr_export_pdf') }}" id="qrForm">
|
||||
<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">
|
||||
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>
|
||||
|
||||
<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 %}
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
<a href="{{ url_for('facilities.list_facilities') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Facilities
|
||||
</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) }}"
|
||||
class="btn btn-outline-info">
|
||||
<i class="bi bi-graph-up-arrow"></i> Scorecard
|
||||
</a>
|
||||
{% 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) }}"
|
||||
class="btn btn-outline-dark" title="Printable QR code for this facility">
|
||||
<i class="bi bi-qr-code"></i> QR Code
|
||||
@@ -135,7 +135,7 @@
|
||||
</td>
|
||||
<td>{{ area.inspections.count() }}</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) }}"
|
||||
class="btn btn-sm btn-outline-dark" title="Printable QR code for this area">
|
||||
<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 %}
|
||||
</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 #}
|
||||
<div id="facility_handler_block" style="display:none;">
|
||||
<div class="mb-2">
|
||||
@@ -166,8 +181,10 @@
|
||||
function syncHandlerUI() {
|
||||
if (!handlerSelect) { return; }
|
||||
var v = handlerSelect.value;
|
||||
var intBlock = document.getElementById('internal_handler_block');
|
||||
var facBlock = document.getElementById('facility_handler_block');
|
||||
var venBlock = document.getElementById('vendor_block');
|
||||
if (intBlock) { intBlock.style.display = (v === 'internal') ? '' : 'none'; }
|
||||
if (facBlock) { facBlock.style.display = (v === 'facility') ? '' : 'none'; }
|
||||
if (venBlock) { venBlock.style.display = (v === 'vendor') ? '' : 'none'; }
|
||||
var desc = document.getElementById('handler_desc');
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<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">
|
||||
<i class="bi bi-plus-circle"></i> Log Issue
|
||||
</a>
|
||||
@@ -167,7 +167,7 @@
|
||||
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||
</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 }}">
|
||||
<select class="form-select form-select-sm quick-assign-select" style="min-width:110px;font-size:.78rem;">
|
||||
<option value="">— Unassigned —</option>
|
||||
@@ -208,7 +208,7 @@
|
||||
|
||||
<a href="{{ url_for('issues.view', issue_id=issue.id) }}"
|
||||
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
|
||||
{% else %}
|
||||
<i class="bi bi-eye"></i> View
|
||||
@@ -290,7 +290,7 @@
|
||||
}());
|
||||
</script>
|
||||
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
{% if current_user.role in ['admin', 'director', 'auditor'] %}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% 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">
|
||||
{# ══════════════════════════════════ LEFT COLUMN ══════════════════════════════════ #}
|
||||
@@ -89,6 +89,17 @@
|
||||
</dt>
|
||||
<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 %}
|
||||
<dt class="col-sm-3">Facility Contact</dt>
|
||||
<dd class="col-sm-9">
|
||||
@@ -179,7 +190,7 @@
|
||||
<div class="alert alert-info py-2 mb-0">
|
||||
<i class="bi bi-hourglass-split me-1"></i>
|
||||
<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">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-2">
|
||||
@@ -307,10 +318,16 @@
|
||||
</div>
|
||||
|
||||
{% 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">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{% else %}
|
||||
@@ -368,7 +385,7 @@
|
||||
{{ form.status.label(class="form-label fw-semibold") }}
|
||||
{{ form.status(class="form-select") }}
|
||||
</div>
|
||||
{% if current_user.role in ['admin','director','project_manager'] %}
|
||||
{% if current_user.role in ['admin','director','project_manager','auditor'] %}
|
||||
{# ── Who handles this issue ── #}
|
||||
<div class="mb-3">
|
||||
{{ form.handler_type.label(class="form-label fw-semibold") }}
|
||||
@@ -377,7 +394,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.role in ['admin','director'] %}
|
||||
{% if current_user.role in ['admin','director','auditor'] %}
|
||||
<div class="mb-3" id="assigned_to_wrap">
|
||||
<label class="form-label fw-semibold" id="assigned_to_label">Assign To</label>
|
||||
{{ form.assigned_to(class="form-select") }}
|
||||
@@ -387,7 +404,27 @@
|
||||
</div>
|
||||
{% 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) ── #}
|
||||
<div id="facility_handler_block" style="display:none;">
|
||||
<hr class="my-3">
|
||||
@@ -558,8 +595,10 @@
|
||||
function syncHandlerUI() {
|
||||
if (!handlerSelect) { return; }
|
||||
var v = handlerSelect.value;
|
||||
var intBlock = document.getElementById('internal_handler_block');
|
||||
var facBlock = document.getElementById('facility_handler_block');
|
||||
var venBlock = document.getElementById('vendor_block');
|
||||
if (intBlock) { intBlock.style.display = (v === 'internal') ? '' : 'none'; }
|
||||
if (facBlock) { facBlock.style.display = (v === 'facility') ? '' : 'none'; }
|
||||
if (venBlock) { venBlock.style.display = (v === 'vendor') ? '' : 'none'; }
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
Let the cleaning team know.
|
||||
</p>
|
||||
|
||||
<form method="POST"
|
||||
<form method="POST" id="reportForm"
|
||||
action="{{ url_for('public.area_report_problem', token=token) }}"
|
||||
enctype="multipart/form-data" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
@@ -160,14 +160,18 @@
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.photo.label(class="form-label small fw-semibold") }}
|
||||
{{ form.photo(class="form-control", accept="image/*") }}
|
||||
{% for e in form.photo.errors %}
|
||||
{{ form.photos.label(class="form-label small fw-semibold") }}
|
||||
{{ form.photos(class="form-control", accept="image/*", id="reportPhotos", multiple=true) }}
|
||||
<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>
|
||||
{% endfor %}
|
||||
</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
|
||||
</button>
|
||||
</form>
|
||||
@@ -177,5 +181,45 @@
|
||||
Janitorial Quality Control
|
||||
</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>
|
||||
</html>
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
Notice something that needs attention? Let the cleaning team know.
|
||||
</p>
|
||||
|
||||
<form method="POST"
|
||||
<form method="POST" id="reportForm"
|
||||
action="{{ url_for('public.report_problem', token=token) }}"
|
||||
enctype="multipart/form-data" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
@@ -159,14 +159,18 @@
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.photo.label(class="form-label small fw-semibold") }}
|
||||
{{ form.photo(class="form-control", accept="image/*") }}
|
||||
{% for e in form.photo.errors %}
|
||||
{{ form.photos.label(class="form-label small fw-semibold") }}
|
||||
{{ form.photos(class="form-control", accept="image/*", id="reportPhotos", multiple=true) }}
|
||||
<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>
|
||||
{% endfor %}
|
||||
</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
|
||||
</button>
|
||||
</form>
|
||||
@@ -176,5 +180,45 @@
|
||||
Janitorial Quality Control
|
||||
</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>
|
||||
</html>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<i class="bi bi-shield-check me-1"></i>SLA Compliance
|
||||
</a>
|
||||
</li>
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'reports.followup_closure' else '' }}"
|
||||
href="{{ url_for('reports.followup_closure') }}">
|
||||
@@ -33,7 +33,7 @@
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('scheduled_reports.') else '' }}"
|
||||
href="{{ url_for('scheduled_reports.index') }}">
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<a href="{{ url_for('inspections.index') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Inspections
|
||||
</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">
|
||||
<i class="bi bi-plus-circle"></i> New Schedule
|
||||
</a>
|
||||
@@ -60,14 +60,14 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<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)) %}
|
||||
<a href="{{ url_for('scheduled_inspections.start', schedule_id=s.id) }}"
|
||||
class="btn btn-sm btn-success" title="Start this inspection">
|
||||
<i class="bi bi-play-fill"></i> Start
|
||||
</a>
|
||||
{% 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) }}"
|
||||
class="btn btn-sm btn-outline-primary"><i class="bi bi-pencil"></i></a>
|
||||
<form method="POST" class="d-inline"
|
||||
@@ -86,7 +86,7 @@
|
||||
{% else %}
|
||||
<div class="p-4 text-muted text-center">
|
||||
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>.
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
+24
-2
@@ -54,17 +54,39 @@ def supervisor_required(f):
|
||||
return decorated_function
|
||||
|
||||
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)
|
||||
def decorated_function(*args, **kwargs):
|
||||
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')
|
||||
return redirect(url_for('dashboard.index'))
|
||||
return f(*args, **kwargs)
|
||||
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):
|
||||
"""Restricts access to customer-role users only.
|
||||
|
||||
|
||||
+11
-4
@@ -55,6 +55,7 @@ class UserForm(FlaskForm):
|
||||
('director', 'Director'),
|
||||
('inspector', 'Inspector'),
|
||||
('project_manager', 'Project Manager'),
|
||||
('auditor', 'Auditor'),
|
||||
# 'customer' is intentionally excluded — customer accounts are managed via /customers
|
||||
], validators=[Optional()])
|
||||
# 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_contact = StringField('Contractor Contact', validators=[Optional(), Length(max=200)])
|
||||
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):
|
||||
@@ -211,6 +215,9 @@ class IssueUpdateForm(FlaskForm):
|
||||
vendor_name = StringField('Contractor Name', validators=[Optional(), Length(max=100)])
|
||||
vendor_contact = StringField('Contractor Contact', validators=[Optional(), Length(max=200)])
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -314,10 +321,10 @@ class PublicIssueReportForm(FlaskForm):
|
||||
validators=[Optional(), Length(max=100)])
|
||||
reporter_contact = StringField('Email or phone (optional)',
|
||||
validators=[Optional(), Length(max=120)])
|
||||
photo = FileField('Add a photo (optional)',
|
||||
validators=[Optional(),
|
||||
FileAllowed(['jpg', 'jpeg', 'png', 'gif'],
|
||||
'Images only (jpg, png, gif).')])
|
||||
photos = MultipleFileField('Add photos (optional, up to 5)',
|
||||
validators=[Optional(),
|
||||
FileAllowed(['jpg', 'jpeg', 'png', 'gif'],
|
||||
'Images only (jpg, png, gif).')])
|
||||
website = StringField('Website') # honeypot — must stay empty
|
||||
|
||||
|
||||
|
||||
@@ -546,6 +546,7 @@ def notify_by_matrix(
|
||||
'director': 'director',
|
||||
'inspector': 'inspector',
|
||||
'project_manager': 'project_manager',
|
||||
'auditor': 'auditor',
|
||||
'customer': 'customer',
|
||||
}
|
||||
|
||||
@@ -568,6 +569,20 @@ def notify_by_matrix(
|
||||
logger.info('MATRIX NOTIFY | event=%s | role=%s | users_found=%s',
|
||||
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
|
||||
if role_key == 'customer' and facility_id:
|
||||
from app.utils.notifications import notify_customers_for_facility
|
||||
|
||||
@@ -1590,3 +1590,93 @@ def generate_facility_summary_pdf(facility, days, start, now,
|
||||
doc.build(story)
|
||||
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()
|
||||
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