Compare commits

..
10 Commits
38 changed files with 942 additions and 159 deletions
+63 -13
View File
@@ -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 AE + 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 R1R4 + 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 3032 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 AE + 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 R1R4 + 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 3032 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 25)** | "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 2432:**
```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
View File
@@ -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:
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+1 -1
View File
@@ -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
View File
@@ -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'])
+1 -1
View File
@@ -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:
+5
View File
@@ -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).
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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'),
)
+121 -14
View File
@@ -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)
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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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',
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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') }}">
+17 -17
View File
@@ -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 &amp; 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">
+1 -1
View File
@@ -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">
+140 -20
View File
@@ -4,42 +4,102 @@
{% 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>
</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="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>
</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>
@@ -49,14 +109,74 @@
alt="QR code for {{ f.name }}" loading="lazy">
</div>
<div class="small">Report a problem &amp; 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 &amp; view recent quality</div>
</label>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info">No active facilities to print QR codes for.</div>
{% endif %}
{% 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 %}
+3 -3
View File
@@ -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>
+17
View File
@@ -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');
+4 -4
View File
@@ -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';
+46 -7
View File
@@ -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'; }
+49 -5
View File
@@ -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>
+49 -5
View File
@@ -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>
+2 -2
View File
@@ -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
View File
@@ -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.
+8 -1
View File
@@ -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,7 +321,7 @@ 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)',
photos = MultipleFileField('Add photos (optional, up to 5)',
validators=[Optional(),
FileAllowed(['jpg', 'jpeg', 'png', 'gif'],
'Images only (jpg, png, gif).')])
+15
View File
@@ -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
+90
View File
@@ -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"))