Merge branch 'main' of https://gitea.ngodanguyen.tech/nngo/LT_Janitorial_Quality_Control
This commit is contained in:
@@ -417,16 +417,44 @@ scheduled_inspections:
|
||||
id, facility_id (FK→facilities CASCADE), template_id (FK→inspection_templates CASCADE),
|
||||
inspector_id (FK→users SET NULL), frequency ENUM('once','daily','weekly','monthly'),
|
||||
next_due_date DATE, active BOOL, notes TEXT, created_by, created_at,
|
||||
last_completed_at DATETIME, advance_notified BOOL, due_notified BOOL, overdue_notified BOOL
|
||||
last_completed_at DATETIME, advance_notified BOOL, due_notified BOOL, overdue_notified BOOL,
|
||||
weekdays VARCHAR(20) nullable, -- Phase 43: CSV weekday ints, Mon=0, e.g. '0,2,4'
|
||||
month_mode VARCHAR(20) nullable, -- Phase 43: 'day_of_month' | 'nth_weekday'
|
||||
day_of_month SMALLINT nullable, -- Phase 43
|
||||
nth_week SMALLINT nullable, -- Phase 43: 1–5, or -1 = last
|
||||
nth_weekday SMALLINT nullable -- Phase 43: 0–6, Mon=0
|
||||
inspections.scheduled_inspection_id FK→scheduled_inspections SET NULL ← Phase 36
|
||||
```
|
||||
|
||||
**Recurrence detail (Phase 43).** `frequency` says *how often*; these columns say *which day*:
|
||||
|
||||
| frequency | columns used | example |
|
||||
|---|---|---|
|
||||
| `once` / `daily` | none (all NULL) | — |
|
||||
| `weekly` | `weekdays` | `'0,2,4'` → Mon/Wed/Fri |
|
||||
| `monthly` + `month_mode='day_of_month'` | `day_of_month` | the 15th (clamped to the month's last day) |
|
||||
| `monthly` + `month_mode='nth_weekday'` | `nth_week`, `nth_weekday` | 2nd Tuesday (`nth_week=-1` → last) |
|
||||
|
||||
All are nullable and **legacy phase36 rows keep NULLs**, falling back to `_add_interval()`'s "+7 days" / "same day next month" — no schedule changes cadence on deploy. `_apply_recurrence()` (in the blueprint) **clears the columns that don't apply** to the chosen frequency, so a weekly→monthly switch can't leave stale weekdays behind.
|
||||
|
||||
- `ScheduledInspection.next_occurrence_after(d)` — first occurrence strictly after `d`, honouring the rule. Used by `fulfill()`; a Mon/Wed/Fri schedule rolls Mon→Wed→Fri→Mon, so **one row yields three inspections a week**.
|
||||
- `align_due_date(d)` — snaps the manager's picked start date forward to the first matching day (pick a Tuesday for Mon/Wed/Fri → get that Wednesday). Applied on both create and edit.
|
||||
- `recurrence_label` — display string (`"Weekly · Mon, Wed, Fri"`), shown on the schedule list and the dashboard panel in place of the bare `frequency_label`.
|
||||
- `weekday_list` / `set_weekdays()` — parse/format the CSV column. `ScheduledInspectionForm(obj=sched)` copies the raw CSV into the multi-select, so the edit route re-assigns `form.weekdays.data = sched.weekday_list` on GET.
|
||||
- A requested 5th weekday that doesn't exist in a month falls back to the 4th; `day_of_month=31` clamps to Feb 28/29. Every month yields a valid date.
|
||||
|
||||
**Duplicate-Start guard.** `start()` returns the existing `in_progress` inspection for the schedule instead of creating a second one, and both the schedule list and the dashboard panel show **Continue** (via `_open_inspection_ids()`) rather than **Start** while one is underway.
|
||||
|
||||
**A plan, not an inspection.** Names a facility + template + assigned inspector + `next_due_date`. Lifecycle:
|
||||
- The assigned inspector (or a manager) clicks **Start** → `scheduled_inspections.start` creates a normal `in_progress` Inspection with `scheduled_inspection_id` set, then redirects to the execute flow.
|
||||
- On **completion** (execute route, status → `completed`), `ScheduledInspection.fulfill()` runs in the same atomic commit: `once` → `active=False`; recurring → `next_due_date` rolls forward past today via `_add_interval()` and the three `*_notified` flags reset.
|
||||
- **End date (phase44).** `end_date` is the manager's boundary; NULL = forever, and it is forced NULL for `once`. Inclusive — an occurrence landing exactly on it still runs. Two enforcement points, both needed: `fulfill()` deactivates when the rolled-forward `next_due_date` passes the boundary (the schedule that ends by being *completed*), and `run_reminders()` calls `expire_if_past_end_date()` on every active schedule before doing any reminder work (the schedule that reaches its boundary *without ever being done* — otherwise it re-alerts as overdue forever). `next_due_date` is left unclamped on expiry so the row shows which occurrence it stopped before.
|
||||
- **Form validation.** `ScheduledInspectionForm.validate()` rejects an end date on a one-time schedule and one earlier than the due date. That is not sufficient alone: `align_due_date()` can push the picked date forward onto the rule (a Tuesday pick on a Mon/Wed/Fri schedule becomes Wednesday), so `_reject_if_past_end_date()` re-checks after `_apply_recurrence()` in both create and edit. Edit rolls back first — `sched` is persistent and already mutated at that point.
|
||||
- **Three status states** in the list: Active, **Ended** (`is_expired` — ran its course), Inactive (a manager switched it off).
|
||||
- **Assignment notification** (immediate): on **create**, the assigned inspector gets an in-app + email "assigned to you" notification; on **edit**, only when the inspector actually changes (a "reassigned to you" notification to the new assignee). Via `_notify_assignee()` in the blueprint using `event_type=EVENT_SCHEDULED_INSPECTION`.
|
||||
- **Reminders** are dispatched by the cron endpoint (see §11): advance (1 day before) + due-date to the inspector, overdue to admin/director — each fires at most once per occurrence via the `*_notified` flags. Uses `notify()` with `event_type=EVENT_SCHEDULED_INSPECTION`.
|
||||
- Dashboard shows an **upcoming (next 7 days) / overdue** panel for non-customers (inspectors see only their own).
|
||||
- **Instructions (July 2026).** `ScheduledInspectionForm.notes` is labelled **"Instructions"** and `scheduled_inspections/form.html` explains that the text reaches the inspector. The *field name*, `ScheduledInspection.notes`, the `scheduled_inspections.notes` column and the API key `notes` are all unchanged — the rename is a label only (rule 84). The text is surfaced to the inspector in two places: `inspections/execute.html` renders an indigo panel between the header and the form grid, guarded on `inspection.scheduled_inspection and .notes` (NULL for ad-hoc work and for schedules deleted after the start); the iPad shows it on the scheduled row, on the start screen and above the form.
|
||||
- **"Scheduled" badge:** an inspection started from a schedule carries `scheduled_inspection_id`. `Inspection.scheduled_inspection` (relationship, foreign_keys on that column) resolves the source schedule (None if ad-hoc or the schedule was later deleted). The inspection **detail** view header shows a `bi-calendar-check` "Scheduled · <frequency>" badge, and the inspection **list** shows a compact "Scheduled" pill next to the template name — both gated on `scheduled_inspection_id` being set.
|
||||
|
||||
Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_required`; **Start** is the **assigned inspector ONLY** (`sched.inspector_id == current_user.id`) — managers do NOT get a Start button and `GET /<id>/start` 403s for anyone who isn't the assignee (the inspection is theirs to do; a manager who must run it assigns it to themselves). The Start button is hidden for non-assignees on both the scheduled-inspections list and the dashboard panel. Inspectors' list/dashboard views are scoped to their own `inspector_id`.
|
||||
@@ -449,6 +477,8 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi
|
||||
| Contracts | ✅ | ✅ | ✅ | read | scoped |
|
||||
| Templates | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| Inspections (execute) | ✅ | ✅ | ✅ | ✅ | read |
|
||||
| Inspection follow-up (request) | ✅ | ✅ | ❌ | ❌ | ✅ own facilities |
|
||||
| Inspection follow-up (clear) | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| Issues (create/assign) | ✅ | ✅ | ✅ | ✅ | ✅ create own |
|
||||
| Issues (quick-assign) | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| Issue verification | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
@@ -621,11 +651,18 @@ The last eight styles (`SummaryTitle` through `TableCell`) were added for the fa
|
||||
|
||||
| Endpoint | Auth | Description |
|
||||
|---|---|---|
|
||||
| `GET /api/v1/scheduled-inspections` | jwt_required | Active scheduled/recurring assignments (`app/api/scheduled.py`, new blueprint). **Inspector:** only rows where `inspector_id == self`. **admin/director/PM:** all active. Sorted by `next_due_date`. Returns per row: `id`, `facility_id`, `facility_name`, `template_id`, `template_name`, `inspector_id`, `frequency`, `frequency_label`, `next_due_date` (ISO date), `is_overdue`, `notes`, plus `total`/`limit`/`offset`. Powers the iPad "Scheduled" section on Dashboard + My Inspections. Read-only — the schedule lifecycle (fulfil/roll-forward) stays web-driven; the iPad "Start" just seeds the new-inspection flow. |
|
||||
| `GET /api/v1/scheduled-inspections` | jwt_required | Active scheduled/recurring assignments (`app/api/scheduled.py`, new blueprint). **Inspector:** only rows where `inspector_id == self`. **admin/director/PM:** all active. Sorted by `next_due_date`. Returns per row: `id`, `facility_id`, `facility_name`, `template_id`, `template_name`, `inspector_id`, `frequency`, `frequency_label`, `next_due_date` (ISO date), `is_overdue`, `notes`, plus `total`/`limit`/`offset`. Powers the iPad "Scheduled" section on Dashboard + My Inspections. Read-only *as a collection* — schedules are created/edited on the web only — but the iPad **does** fulfil them by submitting an inspection with `scheduled_inspection_id` (see below). |
|
||||
| `PATCH /api/v1/issues/<id>/handler` | jwt_required | Set "Handled By" from the iPad (`update_issue_handler`). Body: `{ "handler_type": "internal"\|"facility"\|"vendor", ...optional detail keys }`. Detail keys (`facility_handler_name/contact/notes`, `vendor_name/contact/notes`) are updated only when present; empty string clears a field. **`log_action()` after commit.** |
|
||||
|
||||
**Handler permission divergence — deliberate (see rule 78).** The web issue form limits handler edits to admin/director/PM. This API endpoint additionally allows the assigned **inspector**, scoped by `get_inspector_scope()` (403 if the issue's facility isn't in their contracted set). The iPad is a field tool; inspectors set the handler from Issue Detail. Do not "align" the API back to the web restriction without explicit direction.
|
||||
|
||||
**Schedule fulfilment from the iPad (July 2026 fix).** `POST /api/v1/inspections` and `PATCH /api/v1/inspections/<id>` both accept **`scheduled_inspection_id`**, and both call `_fulfill_schedule()` in the same atomic commit when the inspection reaches `completed` — mirroring the web execute route. Previously the iPad's Start passed only facility + template, so the inspection landed with `scheduled_inspection_id = NULL`: the schedule was never fulfilled (banner stayed on every dashboard, iPad "Scheduled" section never cleared) and the web inspection list showed no "Scheduled" badge. All three symptoms had this single cause.
|
||||
|
||||
- **PATCH fulfils only on the `draft → completed` transition**, so re-PATCHing a completed inspection can't roll a recurring schedule forward twice. POST is guarded by the existing `mobile_local_id` idempotency check (a duplicate returns early, before fulfilment).
|
||||
- **`_resolve_schedule()` is deliberately NON-BLOCKING** — see rule 83.
|
||||
- `_inspection_payload()` returns `scheduled_inspection_id`.
|
||||
- No SyncManager change was needed: `pullScheduledInspections()` already runs after `processInspectionQueue()` in the same `triggerSync()` pass and deletes rows the server no longer returns, so the iPad banner clears on the same sync that submits the inspection.
|
||||
|
||||
The new `scheduled` blueprint is registered in `app/api/__init__.py` and CSRF-exempted in `app/__init__.py` (`csrf.exempt(_api_scheduled_bp)` — parent-exempt does not cascade to child blueprints, per the CSRF pattern above).
|
||||
|
||||
No migration was needed for either feature: the `scheduled_inspections` table (phase36) and the issue handler columns (phase35) already existed; both additions are pure serialization + one new route.
|
||||
@@ -716,12 +753,27 @@ EVENT_CUSTOMER_INSPECTION_DONE = 'customer_inspection_completed'
|
||||
EVENT_CUSTOMER_ISSUE_UPDATED = 'customer_issue_updated'
|
||||
EVENT_SCORE_ALERT = 'score_alert' ← Phase 27
|
||||
EVENT_SCHEDULED_INSPECTION = 'scheduled_inspection' ← Phase 36
|
||||
EVENT_FOLLOWUP_REQUESTED = 'followup_requested' ← Phase 46
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
### Customer-requested follow-up (Phase 46)
|
||||
|
||||
`inspections.flag_followup` is no longer `@supervisor_required`. It gates in the body instead: **admin/director** as before, **plus customers for their own facilities** — a client unhappy with a result asks for a re-inspection directly instead of going through support. Inspector / PM / auditor stay refused (403).
|
||||
|
||||
Customers can only *request*. `clear_followup` remains admin/director, `reinspect()` still refuses customers, and the "Start Re-inspection" button inside the follow-up alert is hidden from them (it 403'd on click before). Three extra customer-only guards in the route:
|
||||
|
||||
- facility must be in `get_customer_scope()` — else 403 (a crafted POST must not reach another client's inspection);
|
||||
- inspection must be `completed` — nothing to follow up on otherwise;
|
||||
- if `follow_up_required` is already set the request is a **no-op**, so a repeat submission can't overwrite the pending note/attribution.
|
||||
|
||||
**Attribution** (`follow_up_requested_by` / `follow_up_requested_at`, phase46) records who asked and when; `clear_followup` nulls both. `inspections/view.html` renders a "Requested by customer" / "Requested by staff" badge from `inspection.follow_up_requester.role`, so staff can see at a glance that a client is waiting.
|
||||
|
||||
**Dispatch** goes through `notify_by_matrix(EVENT_FOLLOWUP_REQUESTED, ...)` — the new `followup_requested` matrix event (admin/director/PM on by default). The inspection's own inspector is notified directly by the route and passed in `exclude_user_ids` so they aren't double-notified; the requester is excluded too. Routing via the matrix (rather than hardcoding managers) is what makes per-contract recipients fire — rule 73. Without it a customer request would reach only the inspector and nobody would own scheduling the re-inspection.
|
||||
|
||||
### 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:
|
||||
@@ -827,7 +879,39 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif
|
||||
→ phase39_area_public_token
|
||||
→ phase40_auditor_role
|
||||
→ phase41_internal_handler
|
||||
→ phase42_internal_contact ← HEAD
|
||||
→ phase42_internal_contact
|
||||
→ phase43_sched_recurrence
|
||||
→ phase44_sched_end_date
|
||||
→ phase45_sched_parent_insp
|
||||
→ phase46_followup_req_by ← HEAD
|
||||
```
|
||||
|
||||
#### phase46 — follow-up request attribution
|
||||
|
||||
Revision id `phase46_followup_req_by` (file `phase46_followup_requested_by.py`, down_revision `phase45_sched_parent_insp`). Adds `inspections.follow_up_requested_by INT NULL` (FK → `users.id` ON DELETE SET NULL) and `follow_up_requested_at DATETIME NULL`. Backs **customer-requested follow-ups** — see §11 "Customer-requested follow-up". **No backfill**: legacy rows keep NULL and render as an unattributed follow-up exactly as before.
|
||||
|
||||
**Breaking detail:** this is the *second* FK from `inspections` to `users`, which made `User.inspections` ambiguous at mapper-configure time (`AmbiguousForeignKeysError` on the first ORM use, not at import). `User.inspections` now declares `foreign_keys='Inspection.inspector_id'` — it means "inspections I performed". Any future FK from `inspections` to `users` needs the same treatment.
|
||||
|
||||
`INFORMATION_SCHEMA` column + constraint checks — safe to re-run.
|
||||
|
||||
**Deploy order:**
|
||||
```bash
|
||||
flask db upgrade
|
||||
sudo systemctl restart gunicorn
|
||||
```
|
||||
|
||||
#### phase44 — scheduled inspection end date
|
||||
|
||||
Revision id `phase44_sched_end_date` (file `phase44_scheduled_end_date.py`, down_revision `phase43_sched_recurrence`). Adds `scheduled_inspections.end_date DATE NULL` — the last date a recurring schedule may produce an occurrence. **No backfill**: NULL means "repeat indefinitely", which is exactly what every existing row does today, so nothing changes cadence on deploy. Splits the two meanings `next_due_date` was carrying (see §5 `ScheduledInspection` and rule 85). `INFORMATION_SCHEMA` column-existence check — safe to re-run.
|
||||
|
||||
### phase43_sched_recurrence
|
||||
|
||||
Revision id `phase43_sched_recurrence`. Adds the five nullable recurrence-detail columns to `scheduled_inspections` (`weekdays`, `month_mode`, `day_of_month`, `nth_week`, `nth_weekday`) so weekly schedules can name their weekdays and monthly schedules can use either a day-of-month or an nth-weekday rule — see §5 `ScheduledInspection`. **No backfill**: existing rows keep NULLs and retain their current cadence. `month_mode` is VARCHAR, not ENUM, so a future recurrence style needs no 3-step ENUM migration (rule 3). `INFORMATION_SCHEMA` column-existence checks — safe to re-run.
|
||||
|
||||
**Deploy order:**
|
||||
```bash
|
||||
flask db upgrade
|
||||
sudo systemctl restart gunicorn
|
||||
```
|
||||
|
||||
### phase21_performance_indexes
|
||||
@@ -1353,6 +1437,11 @@ timeout = 30
|
||||
| 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. |
|
||||
| 86 | **A second FK from a table to `users` breaks any relationship that didn't pin `foreign_keys`** | Adding `inspections.follow_up_requested_by` (phase46) made `User.inspections` ambiguous — `AmbiguousForeignKeysError`, raised at first ORM *use*, not at import, so the app starts fine and then every request 500s. `User.inspections` now pins `foreign_keys='Inspection.inspector_id'`. Check existing relationships before adding another FK to `users` from a table that already has one. |
|
||||
| 83 | **A bad `scheduled_inspection_id` must NEVER fail the inspection submission** | `_resolve_schedule()` in `app/api/inspections.py` drops an unknown or foreign link and logs a warning instead of returning 404/403. The app is offline-first: a completed inspection can sit in the outbox for days, during which the schedule may be deleted, reassigned, or rolled forward. Erroring would burn the 5 sync retries and permanently strand that inspection **and its photos** on the device. A missed fulfil is fixable from the web; a stranded submission is not. The ownership check still refuses to *link* a foreign schedule (one inspector must not fulfil another's) — it just accepts the inspection anyway. |
|
||||
| 82 | **A schedule's recurrence columns must be CLEARED when they don't apply to the chosen frequency** | `_apply_recurrence()` in `routes/scheduled_inspections.py` is the single write path for `frequency` + `weekdays`/`month_mode`/`day_of_month`/`nth_week`/`nth_weekday`, and it NULLs the blocks that don't apply. Setting `sched.frequency` directly (as create/edit used to) leaves stale settings behind — a weekly→monthly switch would keep `weekdays` and `recurrence_label` would lie. The hidden form blocks still POST their values, so client-side hiding is not enough. |
|
||||
| 84 | **"Instructions" is a LABEL over `notes` — never rename the field, attribute, column or API key** | `ScheduledInspectionForm.notes` renders as "Instructions" and both the web execute page and the iPad say "Instructions". The wire key stays `notes` (`api/scheduled.py::_scheduled_payload`), which is what `APIScheduledInspection.notes` decodes into `LocalScheduledInspection.notes`; the iPad exposes it through a computed `instructions` accessor that also trims blank text. Renaming any of the storage identifiers would silently break the iPad decode — the field is `try?`-decoded, so it would fail to nil rather than throwing. |
|
||||
| 85 | **`next_due_date` is mutable state, `end_date` is a fixed boundary — never conflate them** | `fulfill()` rewrites `next_due_date` after every completed inspection; `end_date` is set by the manager and never touched by the app. The old single label "Start / Due Date" said both at once, which is what users reported as confusing. The label now follows context — `form.next_due_date.label.text` is set to "Start Date" in `create()` and "Next Due Date" in `edit()`. Do not rename the `next_due_date` column to match a label: it is indexed, it is the API payload key the iPad decodes, and the reminder cron filters on it. |
|
||||
| 81 | **Photo timestamp/geo overlay is burned at UPLOAD, never on `PATCH /issues/<id>/photos`** | That PATCH receives only path strings — the bytes are already in storage and the payload carries no capture metadata. Burning there would need a read-modify-write per key plus an overwrite-in-place primitive (`storage.save()` mints a NEW uuid key, and §22 requires key == DB path), and would risk a **double burn** since the endpoint is deliberately idempotent/retry-safe (rule 45). Stamp in `POST /photos/upload`, where the raw bytes + EXIF are in hand and each call writes exactly one already-stamped object. Stamping failures must always fall back to storing the ORIGINAL bytes — never lose a photo to a stamping bug. See §23. |
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
JQC WEB — phase44: scheduled inspection End Date
|
||||
================================================
|
||||
Repo: lt_janitorial_quality_control
|
||||
Deploy root: /home/jqc/janitorial_qc/
|
||||
WEB ONLY. No iOS changes, no iPad rebuild.
|
||||
|
||||
NEW FILE
|
||||
migrations/versions/phase44_scheduled_end_date.py
|
||||
revision = 'phase44_sched_end_date'
|
||||
down_revision = 'phase43_sched_recurrence' <- verified current HEAD
|
||||
|
||||
OVERWRITE
|
||||
app/models/scheduled_inspection.py end_date column; is_within_end_date();
|
||||
is_expired; expire_if_past_end_date();
|
||||
fulfill() deactivates past the boundary
|
||||
app/utils/forms.py end_date DateField + validate() rules
|
||||
app/routes/scheduled_inspections.py _apply_recurrence() sets/clears end_date;
|
||||
_reject_if_past_end_date() guard;
|
||||
per-context next_due_date label;
|
||||
run_reminders() expiry sweep; audit detail
|
||||
app/api/scheduled.py 'end_date' in _scheduled_payload
|
||||
app/templates/scheduled_inspections/form.html End Date field + JS toggle
|
||||
app/templates/scheduled_inspections/list.html "Ends" column + "Ended" badge
|
||||
CLAUDE.md
|
||||
|
||||
DEPLOY (migration step first, then code)
|
||||
cd /home/jqc/janitorial_qc
|
||||
git pull
|
||||
|
||||
# 1. MIGRATION
|
||||
source venv/bin/activate
|
||||
flask db current # expect phase43_sched_recurrence
|
||||
flask db upgrade
|
||||
flask db current # expect phase44_sched_end_date
|
||||
|
||||
# 2. CODE
|
||||
sudo systemctl restart janitorial_qc
|
||||
sudo systemctl status janitorial_qc --no-pager
|
||||
|
||||
ROLLBACK
|
||||
flask db downgrade phase43_sched_recurrence # drops end_date, nothing else
|
||||
|
||||
VERIFY
|
||||
1. New Schedule -> frequency "One-time": End Date row is HIDDEN,
|
||||
date field reads "Start Date"
|
||||
2. Switch frequency to Weekly: End Date row appears
|
||||
3. Edit an existing schedule: date field reads "Next Due Date"
|
||||
4. Validation:
|
||||
- end date before the due date -> rejected
|
||||
- end date on a one-time schedule (via curl/devtools) -> rejected
|
||||
- Mon/Wed/Fri, pick a Tuesday, end date that same Tuesday
|
||||
-> rejected, message names the Wednesday
|
||||
5. List: "Ends" column shows the date, "No end" when blank, "—" for one-time
|
||||
6. Existing schedules: unchanged, "No end", still Active, cadence identical
|
||||
7. Boundary: set end date = next due date, complete the inspection
|
||||
-> schedule goes Inactive, badge reads "Ended"
|
||||
8. Cron sweep:
|
||||
curl -X POST "https://jqc.ltservicesinc.com/scheduled-inspections/run?token=$DIGEST_SECRET"
|
||||
-> JSON now includes "expired": N
|
||||
-> a schedule past its end date that was never completed goes Inactive
|
||||
and stops generating overdue alerts
|
||||
|
||||
NOTES
|
||||
- Migration follows the phase42/phase43 idiom in this repo
|
||||
(op.get_bind() + sa.text() + INFORMATION_SCHEMA). Flag if you want the
|
||||
stricter plain-string-only form instead; 62 existing migrations use this one.
|
||||
- api/scheduled.py now returns "end_date". Additive and safe: the iPad
|
||||
decodes explicit CodingKeys, so current builds ignore the new key.
|
||||
+124
-3
@@ -13,6 +13,7 @@ PATCH /api/v1/inspections/<inspection_id>
|
||||
GET /api/v1/inspections
|
||||
Returns the authenticated inspector's own inspection history.
|
||||
Supports ?limit=N&offset=N&facility_id=N&status=completed
|
||||
&follow_up_required=true
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -95,6 +96,58 @@ def _parse_datetime(value):
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_schedule(schedule_id, user):
|
||||
"""Resolve a client-supplied scheduled_inspection_id, or None.
|
||||
|
||||
The iPad sends this when the inspector taps Start on a scheduled row;
|
||||
without it the inspection lands unlinked and the schedule is never fulfilled
|
||||
(no "Scheduled" badge, and the dashboard banner never clears).
|
||||
|
||||
NON-BLOCKING BY DESIGN. A bad link drops the link and logs a warning — it
|
||||
never fails the submission. The app is offline-first, so a schedule can
|
||||
legitimately be deleted or reassigned while a completed inspection sits in
|
||||
the outbox for days; erroring here would retry-fail that inspection and
|
||||
strand the inspector's work (and its photos) permanently. A missed fulfil is
|
||||
recoverable from the web UI; a stranded submission is not.
|
||||
|
||||
The ownership check still matters: accepting a foreign link would let one
|
||||
inspector fulfil another's schedule. So the link is refused — but the
|
||||
inspection itself is still accepted.
|
||||
"""
|
||||
from app.models.scheduled_inspection import ScheduledInspection
|
||||
|
||||
sched = db.session.get(ScheduledInspection, schedule_id)
|
||||
if sched is None:
|
||||
logger.warning('API INSPECTIONS | unknown scheduled_inspection_id=%s from user=%s '
|
||||
'— submitting unlinked', schedule_id, user.username)
|
||||
return None
|
||||
if user.role == 'inspector' and sched.inspector_id != user.id:
|
||||
logger.warning('API INSPECTIONS | scheduled_inspection_id=%s not assigned to user=%s '
|
||||
'— submitting unlinked', schedule_id, user.username)
|
||||
return None
|
||||
return sched
|
||||
|
||||
|
||||
def _fulfill_schedule(inspection):
|
||||
"""Roll the originating schedule forward / deactivate it. Caller commits.
|
||||
|
||||
Mirrors the web execute route: one-time schedules deactivate (so the
|
||||
dashboard banner, which filters on active, disappears), recurring ones
|
||||
advance to their next occurrence and reset the reminder flags.
|
||||
"""
|
||||
if not inspection.scheduled_inspection_id:
|
||||
return
|
||||
from app.models.scheduled_inspection import ScheduledInspection
|
||||
|
||||
sched = db.session.get(ScheduledInspection, inspection.scheduled_inspection_id)
|
||||
if sched is None:
|
||||
return
|
||||
sched.fulfill()
|
||||
logger.info('API INSPECTIONS | schedule fulfilled | schedule=%s | inspection=%s | next=%s',
|
||||
sched.id, inspection.id,
|
||||
sched.next_due_date if sched.active else 'deactivated')
|
||||
|
||||
|
||||
def _media(key):
|
||||
"""Absolute display URL for a storage key (presigned on R2, absolute-static
|
||||
on local). '' for falsy keys. Used for iPad image rendering."""
|
||||
@@ -157,6 +210,10 @@ def _inspection_payload(inspection):
|
||||
'follow_up_required': inspection.follow_up_required,
|
||||
'follow_up_note': inspection.follow_up_note,
|
||||
'parent_inspection_id': inspection.parent_inspection_id,
|
||||
# Set when this inspection was started from a ScheduledInspection —
|
||||
# drives the "Scheduled" badge on the web list and lets the iPad show
|
||||
# the same marker in history.
|
||||
'scheduled_inspection_id': inspection.scheduled_inspection_id,
|
||||
}
|
||||
|
||||
|
||||
@@ -179,6 +236,13 @@ def list_inspections():
|
||||
status str filter by status (completed, in_progress, flagged)
|
||||
from_date str ISO date (YYYY-MM-DD) — include inspections on/after this date
|
||||
to_date str ISO date (YYYY-MM-DD) — include inspections on/before this date
|
||||
follow_up_required
|
||||
str "true"/"1" — only inspections a director has flagged as
|
||||
needing a follow-up and that no re-inspection has answered
|
||||
yet (flagged + completed + no child), matching what
|
||||
"Follow-up" means on the web. Drives the iPad's FOLLOW-UP
|
||||
REQUESTED card, so it must return the complete outstanding
|
||||
set, not just the recent page the history list shows.
|
||||
|
||||
Response 200
|
||||
------------
|
||||
@@ -215,6 +279,24 @@ def list_inspections():
|
||||
if status:
|
||||
query = query.filter(Inspection.status == status)
|
||||
|
||||
if request.args.get('follow_up_required', '').lower() in ('true', '1'):
|
||||
# Must mean exactly what "Follow-up" means everywhere on the web
|
||||
# (inspections.list / reports status_filter == 'follow_up'): flagged,
|
||||
# completed, and not yet answered by a linked re-inspection.
|
||||
#
|
||||
# The ~follow_ups.any() clause is the one that matters. The web execute
|
||||
# route never clears follow_up_required on the parent — it only stops
|
||||
# listing it once a child exists — so filtering on the flag alone would
|
||||
# return follow-ups that were already satisfied on the web, forever.
|
||||
# On the iPad those rows are undismissable: pull_follow_up_requests()
|
||||
# keeps receiving them and update(from:) resets fulfilledLocally, so the
|
||||
# FOLLOW-UP REQUESTED card would never clear. (The mobile POST path does
|
||||
# clear the parent flag, so only web-completed re-inspections stick.)
|
||||
query = query.filter(
|
||||
Inspection.follow_up_required.is_(True),
|
||||
Inspection.status == 'completed',
|
||||
).filter(~Inspection.follow_ups.any())
|
||||
|
||||
from_date_str = request.args.get('from_date')
|
||||
if from_date_str:
|
||||
try:
|
||||
@@ -333,6 +415,25 @@ def create_inspection():
|
||||
if parent is None:
|
||||
return api_error('Parent inspection not found', 404)
|
||||
|
||||
# ── Optional schedule link (started from a ScheduledInspection) ───────
|
||||
scheduled_inspection_id = None
|
||||
if data.get('scheduled_inspection_id'):
|
||||
sched = _resolve_schedule(data['scheduled_inspection_id'], user)
|
||||
scheduled_inspection_id = sched.id if sched else None
|
||||
|
||||
# phase45 — inherit the follow-up link from the schedule when the
|
||||
# client did not send one. A schedule created by "Schedule Follow-up"
|
||||
# knows which inspection it answers, so the link should not depend on
|
||||
# the client remembering to pass it: an older build, or a draft resumed
|
||||
# after the cached row was refreshed, would otherwise submit a plain
|
||||
# inspection and leave the parent flagged forever. Never overrides an
|
||||
# explicit parent_inspection_id.
|
||||
if not parent_inspection_id and sched and sched.parent_inspection_id:
|
||||
parent_inspection_id = sched.parent_inspection_id
|
||||
logger.info('API INSPECTIONS | parent inherited from schedule | '
|
||||
'schedule=%s | parent=%s | user=%s',
|
||||
sched.id, parent_inspection_id, user.username)
|
||||
|
||||
# ── Score calculation ─────────────────────────────────────────────────
|
||||
overall_score = data.get('overall_score')
|
||||
if overall_score is None and status == 'completed':
|
||||
@@ -383,11 +484,19 @@ def create_inspection():
|
||||
parent_inspection_id = parent_inspection_id,
|
||||
submit_latitude = submit_latitude,
|
||||
submit_longitude = submit_longitude,
|
||||
scheduled_inspection_id = scheduled_inspection_id,
|
||||
)
|
||||
|
||||
db.session.add(inspection)
|
||||
db.session.flush()
|
||||
|
||||
# ── Fulfil the originating schedule ───────────────────────────────────
|
||||
# Staged into the same atomic commit as the inspection, mirroring the web
|
||||
# execute route. Without this the schedule stays active: the dashboard
|
||||
# banner and the iPad "Scheduled" section never clear.
|
||||
if status == 'completed':
|
||||
_fulfill_schedule(inspection)
|
||||
|
||||
# ── Auto-clear follow-up flag on parent ───────────────────────────────
|
||||
# When a completed re-inspection arrives that links to a parent, clear
|
||||
# follow_up_required on the parent automatically. This mirrors the web
|
||||
@@ -512,6 +621,13 @@ def update_inspection(inspection_id):
|
||||
|
||||
prev_status = inspection.status
|
||||
|
||||
# Allow the link to be set/corrected on PATCH too — the iPad may create the
|
||||
# inspection as a draft first and only attach the schedule on submit.
|
||||
if data.get('scheduled_inspection_id'):
|
||||
sched = _resolve_schedule(data['scheduled_inspection_id'], user)
|
||||
if sched is not None:
|
||||
inspection.scheduled_inspection_id = sched.id
|
||||
|
||||
if 'status' in data:
|
||||
inspection.status = data['status']
|
||||
|
||||
@@ -527,12 +643,17 @@ def update_inspection(inspection_id):
|
||||
elif data.get('status') == 'completed' and not inspection.completed_at:
|
||||
inspection.completed_at = now_eastern()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Notify when a draft transitions to completed — mirrors the POST handler.
|
||||
transitioning_to_complete = (
|
||||
data.get('status') == 'completed' and prev_status != 'completed'
|
||||
)
|
||||
# Fulfil the schedule on the draft → completed transition only, so a later
|
||||
# PATCH on an already-completed inspection can't roll it forward twice.
|
||||
if transitioning_to_complete:
|
||||
_fulfill_schedule(inspection)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Notify when a draft transitions to completed — mirrors the POST handler.
|
||||
if transitioning_to_complete:
|
||||
score_val = inspection.overall_score
|
||||
score_display = f'{score_val:.1f}%' if score_val is not None else 'N/A'
|
||||
|
||||
@@ -17,12 +17,16 @@ app/models/scheduled_inspection.py for the full lifecycle.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, request, g
|
||||
from app import db
|
||||
from app.models.scheduled_inspection import ScheduledInspection
|
||||
from app.models.inspection import Inspection
|
||||
from app.api.errors import api_ok, api_error
|
||||
from app.api.decorators import jwt_required
|
||||
from app.utils.scope import get_inspector_scope
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -42,9 +46,25 @@ def _scheduled_payload(s):
|
||||
'inspector_id': s.inspector_id,
|
||||
'frequency': s.frequency,
|
||||
'frequency_label': s.frequency_label,
|
||||
# phase43 recurrence detail. `recurrence_label` is the display string
|
||||
# ("Weekly · Mon, Wed, Fri"); the raw fields let the iPad render its own.
|
||||
'recurrence_label': s.recurrence_label,
|
||||
'weekdays': s.weekday_list,
|
||||
'month_mode': s.month_mode,
|
||||
'day_of_month': s.day_of_month,
|
||||
'nth_week': s.nth_week,
|
||||
'nth_weekday': s.nth_weekday,
|
||||
'next_due_date': s.next_due_date.isoformat() if s.next_due_date else None,
|
||||
# phase44. Additive: the iPad decodes explicit CodingKeys, so a build
|
||||
# that predates this key ignores it rather than failing to decode.
|
||||
'end_date': s.end_date.isoformat() if s.end_date else None,
|
||||
'is_overdue': s.is_overdue(),
|
||||
'notes': s.notes or None,
|
||||
# phase45. Set when this schedule is a planned follow-up of a specific
|
||||
# inspection; the iPad carries it onto the inspection it starts so the
|
||||
# run lands as a linked re-inspection. Additive — older builds decode
|
||||
# explicit CodingKeys and ignore it.
|
||||
'parent_inspection_id': s.parent_inspection_id,
|
||||
}
|
||||
|
||||
|
||||
@@ -102,3 +122,118 @@ def list_scheduled():
|
||||
|
||||
return api_ok({'scheduled': payload, 'total': total,
|
||||
'limit': limit, 'offset': offset})
|
||||
|
||||
|
||||
# ── Create a scheduled follow-up (phase45) ────────────────────────────────────
|
||||
|
||||
@bp.route('/scheduled-inspections/follow-up', methods=['POST'])
|
||||
@jwt_required
|
||||
def create_follow_up():
|
||||
"""
|
||||
Plan a follow-up re-inspection of a completed inspection for a later date.
|
||||
|
||||
Backs "Schedule Follow-up" in the iPad's inspection history detail, the
|
||||
deferred twin of "Re-inspect Now". Creates a one-time schedule carrying
|
||||
`parent_inspection_id`, so the inspection eventually started from it is a
|
||||
true linked re-inspection.
|
||||
|
||||
Deliberately narrow: this is not a general schedule-creation endpoint. The
|
||||
facility, template and assignee are all derived from the parent inspection
|
||||
rather than taken from the client, so a follow-up can only ever target the
|
||||
thing it is a follow-up of. Recurring schedules stay web-only
|
||||
(`@project_manager_required`).
|
||||
|
||||
Request body
|
||||
------------
|
||||
parent_inspection_id int required — the completed inspection to follow up
|
||||
due_date str required — ISO date (YYYY-MM-DD), today or later
|
||||
notes str optional — what the follow-up should address
|
||||
|
||||
Response 200/201
|
||||
----------------
|
||||
{ "ok": true, "data": { "scheduled": {...}, "created": true } }
|
||||
"""
|
||||
user = g.api_user
|
||||
|
||||
# Auditor is read-only everywhere else; keep it that way here.
|
||||
if user.role not in {'admin', 'director', 'inspector', 'project_manager'}:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
|
||||
parent_id = body.get('parent_inspection_id')
|
||||
if not isinstance(parent_id, int):
|
||||
return api_error('parent_inspection_id is required', 400)
|
||||
|
||||
parent = db.session.get(Inspection, parent_id)
|
||||
if parent is None:
|
||||
return api_error('Inspection not found', 404)
|
||||
|
||||
# An inspector may only schedule a follow-up of their own work, and only
|
||||
# within their assigned contracts — the same two gates the rest of the
|
||||
# mobile API applies. Managers are unrestricted, matching the web.
|
||||
if user.role == 'inspector':
|
||||
if parent.inspector_id != user.id:
|
||||
return api_error('Access denied', 403)
|
||||
fids = get_inspector_scope(user)
|
||||
if not fids or parent.facility_id not in fids:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
# A follow-up only makes sense once there is something to follow up on.
|
||||
if parent.status != 'completed':
|
||||
return api_error('Only a completed inspection can have a follow-up '
|
||||
'scheduled', 400)
|
||||
|
||||
due_raw = (body.get('due_date') or '').strip()
|
||||
try:
|
||||
due_date = datetime.strptime(due_raw, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
return api_error('due_date must be an ISO date (YYYY-MM-DD)', 400)
|
||||
|
||||
# Today is allowed — "later today" is a legitimate plan; yesterday is not.
|
||||
if due_date < now_eastern().date():
|
||||
return api_error('due_date cannot be in the past', 400)
|
||||
|
||||
notes = (body.get('notes') or '').strip() or None
|
||||
|
||||
# Idempotent: the iPad may retry a request whose response was lost, and a
|
||||
# second identical schedule would put a duplicate row in the inspector's
|
||||
# Scheduled list with no way to tell them apart. Reuse the existing active
|
||||
# follow-up for this parent instead, updating the date they just picked.
|
||||
existing = (ScheduledInspection.query
|
||||
.filter_by(parent_inspection_id=parent.id, active=True)
|
||||
.order_by(ScheduledInspection.id.desc())
|
||||
.first())
|
||||
if existing is not None:
|
||||
existing.next_due_date = due_date
|
||||
if notes:
|
||||
existing.notes = notes
|
||||
db.session.commit()
|
||||
logger.info('API SCHEDULED | follow-up updated | schedule=%s | '
|
||||
'parent=%s | due=%s | user=%s',
|
||||
existing.id, parent.id, due_date, user.username)
|
||||
return api_ok({'scheduled': _scheduled_payload(existing),
|
||||
'created': False})
|
||||
|
||||
sched = ScheduledInspection(
|
||||
facility_id = parent.facility_id,
|
||||
template_id = parent.template_id,
|
||||
# Assign to whoever performed the original — they are the one being
|
||||
# asked to put it right. Falls back to the caller when the parent has
|
||||
# no inspector (its account was deleted).
|
||||
inspector_id = parent.inspector_id or user.id,
|
||||
frequency = 'once',
|
||||
next_due_date = due_date,
|
||||
active = True,
|
||||
notes = notes,
|
||||
parent_inspection_id = parent.id,
|
||||
created_by = user.id,
|
||||
)
|
||||
db.session.add(sched)
|
||||
db.session.commit()
|
||||
|
||||
logger.info('API SCHEDULED | follow-up created | schedule=%s | parent=%s | '
|
||||
'facility=%s | due=%s | user=%s',
|
||||
sched.id, parent.id, parent.facility_id, due_date, user.username)
|
||||
|
||||
return api_ok({'scheduled': _scheduled_payload(sched), 'created': True}, 201)
|
||||
|
||||
@@ -81,6 +81,14 @@ class Inspection(db.Model):
|
||||
)
|
||||
follow_up_required = db.Column(db.Boolean, nullable=False, default=False)
|
||||
follow_up_note = db.Column(db.Text, nullable=True)
|
||||
# Who asked for the follow-up (phase46). NULL for legacy rows flagged before
|
||||
# the column existed. Matters because customers can now raise the request
|
||||
# themselves — staff need to see at a glance that the client is waiting on
|
||||
# this one, not another internal reviewer.
|
||||
follow_up_requested_by = db.Column(
|
||||
db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True
|
||||
)
|
||||
follow_up_requested_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
results = db.relationship('InspectionResult', backref='inspection', lazy='dynamic', cascade='all, delete-orphan')
|
||||
issues = db.relationship('Issue', backref='inspection', lazy='dynamic', cascade='all, delete-orphan')
|
||||
@@ -88,6 +96,10 @@ class Inspection(db.Model):
|
||||
# None for ad-hoc/manual inspections or if the schedule was later deleted.
|
||||
scheduled_inspection = db.relationship('ScheduledInspection',
|
||||
foreign_keys=[scheduled_inspection_id])
|
||||
# The user who requested the follow-up (phase46) — a customer or a manager.
|
||||
# Explicit foreign_keys: `inspector_id` also points at users.
|
||||
follow_up_requester = db.relationship('User',
|
||||
foreign_keys=[follow_up_requested_by])
|
||||
follow_ups = db.relationship('Inspection', backref=db.backref('parent', remote_side='Inspection.id'),
|
||||
lazy='dynamic', foreign_keys='Inspection.parent_inspection_id')
|
||||
|
||||
|
||||
@@ -33,6 +33,11 @@ EVENT_ADMIN_BROADCAST = 'admin_broadcast' # bulk messages sent by admin to all
|
||||
# overdue to admin/director). Phase 36.
|
||||
EVENT_SCHEDULED_INSPECTION = 'scheduled_inspection'
|
||||
|
||||
# Fired when someone asks for a follow-up re-inspection of a completed
|
||||
# inspection. Raised by admin/director from the inspection page and — since
|
||||
# phase46 — by CUSTOMERS for their own facilities. Phase 46.
|
||||
EVENT_FOLLOWUP_REQUESTED = 'followup_requested'
|
||||
|
||||
ALL_EVENT_TYPES = {
|
||||
EVENT_ISSUE_ASSIGNED: 'Issue assigned to me',
|
||||
EVENT_ISSUE_STATUS: 'Issue status changed',
|
||||
@@ -49,6 +54,7 @@ ALL_EVENT_TYPES = {
|
||||
EVENT_SCORE_ALERT: 'Facility score trend alert (significant drop detected)',
|
||||
# Scheduled inspection reminders (due/advance/overdue)
|
||||
EVENT_SCHEDULED_INSPECTION: 'Scheduled inspection reminders (due / overdue)',
|
||||
EVENT_FOLLOWUP_REQUESTED: 'Follow-up re-inspection requested',
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ issue_flagged : admin ✓ director ✓ inspector ✗ pm ✗ cust
|
||||
issue_created : admin ✗ director ✗ inspector ✗ pm ✗ customer ✓ (assignee implicit)
|
||||
issue_updated_customer : admin ✗ director ✗ inspector ✗ pm ✗ customer ✓
|
||||
verification_requested : admin ✓ director ✓ inspector ✗ pm ✗ customer ✗
|
||||
followup_requested : admin ✓ director ✓ inspector ✗ pm ✓ customer ✗ (inspection's own inspector implicit)
|
||||
sla_alert : admin ✓ director ✗ inspector ✗ pm ✗ customer ✗ (assignee + followers implicit)
|
||||
score_alert : admin ✓ director ✓ inspector ✗ pm ✗ customer ✗ (facility score drop cron)
|
||||
"""
|
||||
@@ -63,6 +64,7 @@ MATRIX_EVENTS = {
|
||||
'issue_created': 'Issue created (standalone)',
|
||||
'issue_updated_customer': 'Issue updated (customer)',
|
||||
'verification_requested': 'Verification requested',
|
||||
'followup_requested': 'Follow-up requested (incl. by customer)',
|
||||
'sla_alert': 'SLA at-risk / breached',
|
||||
'score_alert': 'Facility score trend alert (significant drop)',
|
||||
}
|
||||
@@ -147,6 +149,16 @@ MATRIX_DEFAULTS = {
|
||||
('verification_requested', 'project_manager'): False,
|
||||
('verification_requested', 'customer'): False,
|
||||
('verification_requested', 'custom'): False,
|
||||
# followup_requested — a customer (or manager) asks for a re-inspection.
|
||||
# On for the roles who action it; the inspection's own inspector is
|
||||
# notified directly by the route, so the inspector column stays off to
|
||||
# avoid alerting the whole inspector pool.
|
||||
('followup_requested', 'admin'): True,
|
||||
('followup_requested', 'director'): True,
|
||||
('followup_requested', 'inspector'): False,
|
||||
('followup_requested', 'project_manager'): True,
|
||||
('followup_requested', 'customer'): False,
|
||||
('followup_requested', 'custom'): False,
|
||||
# sla_alert (assignee + followers always notified implicitly)
|
||||
('sla_alert', 'admin'): True,
|
||||
('sla_alert', 'director'): False,
|
||||
|
||||
@@ -17,15 +17,64 @@ POST /scheduled-inspections/run?token=DIGEST_SECRET:
|
||||
- overdue alert to admin/director once the due date passes uncompleted
|
||||
The *_notified flags make each of those fire at most once per occurrence and
|
||||
reset when a recurring schedule rolls forward.
|
||||
|
||||
Two dates, deliberately distinct (phase44):
|
||||
next_due_date — mutable state. The next occurrence. Rewritten by fulfill()
|
||||
after every completed inspection.
|
||||
end_date — fixed boundary. The last date an occurrence may fall on,
|
||||
set by the manager and never rewritten. NULL = forever.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
import calendar
|
||||
from datetime import date, timedelta
|
||||
|
||||
from app import db
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
|
||||
FREQUENCY_CHOICES = ('once', 'daily', 'weekly', 'monthly')
|
||||
|
||||
# Monthly recurrence styles (phase43). Stored as VARCHAR, not ENUM, so adding a
|
||||
# style later needs no 3-step MySQL ENUM dance (CLAUDE.md rule 3).
|
||||
MONTH_MODE_DAY = 'day_of_month' # "the 15th of every month"
|
||||
MONTH_MODE_NTH = 'nth_weekday' # "the 2nd Tuesday of every month"
|
||||
|
||||
# Python weekday numbering: Monday=0 … Sunday=6 (matches date.weekday()).
|
||||
WEEKDAY_NAMES = ('Monday', 'Tuesday', 'Wednesday', 'Thursday',
|
||||
'Friday', 'Saturday', 'Sunday')
|
||||
WEEKDAY_ABBREV = ('Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun')
|
||||
|
||||
# nth_week: 1–4 are literal, 5 means "5th (or last if the month is short)",
|
||||
# -1 means "last" explicitly.
|
||||
NTH_WEEK_LABELS = {1: '1st', 2: '2nd', 3: '3rd', 4: '4th', 5: '5th', -1: 'Last'}
|
||||
|
||||
|
||||
def _last_day_of(year, month):
|
||||
return calendar.monthrange(year, month)[1]
|
||||
|
||||
|
||||
def _shift_month(year, month, n=1):
|
||||
"""Return (year, month) shifted by *n* months."""
|
||||
idx = year * 12 + (month - 1) + n
|
||||
return idx // 12, idx % 12 + 1
|
||||
|
||||
|
||||
def _nth_weekday_of(year, month, weekday, nth):
|
||||
"""Date of the *nth* *weekday* in a month.
|
||||
|
||||
``nth == -1`` means the last one. A requested 5th occurrence that does not
|
||||
exist falls back to the 4th, so every month yields a valid date.
|
||||
"""
|
||||
last = _last_day_of(year, month)
|
||||
if nth == -1:
|
||||
d = date(year, month, last)
|
||||
return d - timedelta(days=(d.weekday() - weekday) % 7)
|
||||
first = date(year, month, 1)
|
||||
day = 1 + ((weekday - first.weekday()) % 7) + (nth - 1) * 7
|
||||
while day > last:
|
||||
day -= 7
|
||||
return date(year, month, day)
|
||||
|
||||
|
||||
class ScheduledInspection(db.Model):
|
||||
__tablename__ = 'scheduled_inspections'
|
||||
@@ -42,9 +91,40 @@ class ScheduledInspection(db.Model):
|
||||
nullable=True, index=True)
|
||||
frequency = db.Column(db.Enum(*FREQUENCY_CHOICES), nullable=False, default='once')
|
||||
next_due_date = db.Column(db.Date, nullable=False, index=True)
|
||||
# Fixed boundary set by the manager, never rewritten by the app — unlike
|
||||
# next_due_date, which fulfill() advances after every completed inspection.
|
||||
# NULL = repeat indefinitely. Only meaningful for recurring schedules; the
|
||||
# create/edit routes force it to NULL when frequency == 'once'.
|
||||
end_date = db.Column(db.Date, nullable=True)
|
||||
active = db.Column(db.Boolean, nullable=False, default=True)
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
# ── Follow-up link (phase45) ─────────────────────────────────────────────
|
||||
# Set when this schedule was created as a follow-up of a specific completed
|
||||
# inspection ("Schedule Follow-up" in the iPad's history detail). The
|
||||
# inspection eventually started from this schedule inherits it as its
|
||||
# parent_inspection_id, so it lands as a true linked re-inspection —
|
||||
# pre-filled from the parent and clearing the parent's follow_up_required on
|
||||
# submit. NULL = an ordinary schedule, which is what every pre-phase45 row
|
||||
# is.
|
||||
parent_inspection_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('inspections.id', ondelete='SET NULL'),
|
||||
nullable=True, index=True,
|
||||
)
|
||||
|
||||
# ── Recurrence detail (phase43) ──────────────────────────────────────────
|
||||
# weekly : CSV of Python weekday ints, e.g. '0,2,4' = Mon/Wed/Fri.
|
||||
# NULL/empty falls back to the legacy "every 7 days" behaviour.
|
||||
# monthly : month_mode picks which pair of columns applies —
|
||||
# MONTH_MODE_DAY → day_of_month; MONTH_MODE_NTH → nth_week + nth_weekday.
|
||||
# NULL falls back to the legacy "same day next month" behaviour.
|
||||
weekdays = db.Column(db.String(20), nullable=True)
|
||||
month_mode = db.Column(db.String(20), nullable=True)
|
||||
day_of_month = db.Column(db.SmallInteger, nullable=True)
|
||||
nth_week = db.Column(db.SmallInteger, nullable=True)
|
||||
nth_weekday = db.Column(db.SmallInteger, nullable=True)
|
||||
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'),
|
||||
nullable=True)
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
|
||||
@@ -60,6 +140,13 @@ class ScheduledInspection(db.Model):
|
||||
template = db.relationship('InspectionTemplate', foreign_keys=[template_id])
|
||||
inspector = db.relationship('User', foreign_keys=[inspector_id])
|
||||
creator = db.relationship('User', foreign_keys=[created_by])
|
||||
# Explicit foreign_keys is required, not optional: inspections and
|
||||
# scheduled_inspections now reference each other (Inspection
|
||||
# .scheduled_inspection_id points here, parent_inspection_id points back),
|
||||
# so SQLAlchemy cannot infer the join for either side. Inspection
|
||||
# .scheduled_inspection is already declared the same way.
|
||||
parent_inspection = db.relationship('Inspection',
|
||||
foreign_keys=[parent_inspection_id])
|
||||
|
||||
FREQUENCY_LABELS = {
|
||||
'once': 'One-time',
|
||||
@@ -72,42 +159,166 @@ class ScheduledInspection(db.Model):
|
||||
def frequency_label(self):
|
||||
return self.FREQUENCY_LABELS.get(self.frequency, self.frequency)
|
||||
|
||||
# ── Recurrence accessors ─────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def weekday_list(self):
|
||||
"""Selected weekdays as a sorted list of ints (Mon=0). [] if unset."""
|
||||
if not self.weekdays:
|
||||
return []
|
||||
out = set()
|
||||
for part in str(self.weekdays).split(','):
|
||||
part = part.strip()
|
||||
if part.lstrip('-').isdigit() and 0 <= int(part) <= 6:
|
||||
out.add(int(part))
|
||||
return sorted(out)
|
||||
|
||||
def set_weekdays(self, values):
|
||||
"""Store an iterable of weekday ints as the CSV column (None if empty)."""
|
||||
clean = sorted({int(v) for v in (values or []) if 0 <= int(v) <= 6})
|
||||
self.weekdays = ','.join(str(v) for v in clean) or None
|
||||
|
||||
@property
|
||||
def recurrence_label(self):
|
||||
"""Human summary of the recurrence rule, e.g. 'Weekly · Mon, Wed, Fri'."""
|
||||
base = self.frequency_label
|
||||
if self.frequency == 'weekly':
|
||||
days = self.weekday_list
|
||||
if days:
|
||||
return f"{base} · {', '.join(WEEKDAY_ABBREV[d] for d in days)}"
|
||||
elif self.frequency == 'monthly':
|
||||
if self.month_mode == MONTH_MODE_NTH and self.nth_week and self.nth_weekday is not None:
|
||||
nth = NTH_WEEK_LABELS.get(self.nth_week, str(self.nth_week))
|
||||
return f'{base} · {nth} {WEEKDAY_NAMES[self.nth_weekday]}'
|
||||
if self.day_of_month:
|
||||
return f'{base} · day {self.day_of_month}'
|
||||
return base
|
||||
|
||||
# ── Date arithmetic ──────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _add_interval(d, frequency):
|
||||
"""Return d advanced by one interval of the given frequency."""
|
||||
"""Return d advanced by one plain interval of *frequency*.
|
||||
|
||||
Fallback used when no day-of-week / day-of-month detail is configured
|
||||
(legacy phase36 rows). Prefer :meth:`next_occurrence_after`.
|
||||
"""
|
||||
if frequency == 'daily':
|
||||
return d + timedelta(days=1)
|
||||
if frequency == 'weekly':
|
||||
return d + timedelta(weeks=1)
|
||||
if frequency == 'monthly':
|
||||
# Add ~1 month by stepping 28–31 days to the same day-of-month where possible.
|
||||
month = d.month + 1
|
||||
year = d.year + (1 if month > 12 else 0)
|
||||
month = 1 if month > 12 else month
|
||||
day = min(d.day, 28) # clamp to avoid invalid dates (e.g. Feb 30)
|
||||
return d.replace(year=year, month=month, day=day)
|
||||
year, month = _shift_month(d.year, d.month, 1)
|
||||
return date(year, month, min(d.day, _last_day_of(year, month)))
|
||||
return d # 'once' has no next interval
|
||||
|
||||
def next_occurrence_after(self, d):
|
||||
"""First occurrence strictly after date *d*, honouring the day rules."""
|
||||
if self.frequency == 'weekly':
|
||||
days = self.weekday_list
|
||||
if days:
|
||||
for step in range(1, 8):
|
||||
cand = d + timedelta(days=step)
|
||||
if cand.weekday() in days:
|
||||
return cand
|
||||
elif self.frequency == 'monthly':
|
||||
year, month = _shift_month(d.year, d.month, 1)
|
||||
if self.month_mode == MONTH_MODE_NTH and self.nth_week and self.nth_weekday is not None:
|
||||
return _nth_weekday_of(year, month, self.nth_weekday, self.nth_week)
|
||||
if self.day_of_month:
|
||||
return date(year, month, min(self.day_of_month, _last_day_of(year, month)))
|
||||
return self._add_interval(d, self.frequency)
|
||||
|
||||
def align_due_date(self, d):
|
||||
"""Snap *d* forward to the first date on/after it that fits the rule.
|
||||
|
||||
Lets a manager pick any start date and still get, say, Mon/Wed/Fri:
|
||||
picking a Tuesday for a Mon/Wed/Fri schedule yields that Wednesday.
|
||||
"""
|
||||
if self.frequency == 'weekly':
|
||||
days = self.weekday_list
|
||||
if days:
|
||||
for step in range(0, 7):
|
||||
cand = d + timedelta(days=step)
|
||||
if cand.weekday() in days:
|
||||
return cand
|
||||
elif self.frequency == 'monthly':
|
||||
if self.month_mode == MONTH_MODE_NTH and self.nth_week and self.nth_weekday is not None:
|
||||
cand = _nth_weekday_of(d.year, d.month, self.nth_weekday, self.nth_week)
|
||||
elif self.day_of_month:
|
||||
cand = date(d.year, d.month,
|
||||
min(self.day_of_month, _last_day_of(d.year, d.month)))
|
||||
else:
|
||||
return d
|
||||
if cand < d:
|
||||
return self.next_occurrence_after(cand)
|
||||
return cand
|
||||
return d
|
||||
|
||||
def is_overdue(self, today=None):
|
||||
today = today or now_eastern().date()
|
||||
return self.active and self.next_due_date < today
|
||||
|
||||
# ── End-date boundary (phase44) ──────────────────────────────────────────
|
||||
|
||||
def is_within_end_date(self, d):
|
||||
"""True if date *d* is on or before the end date (inclusive).
|
||||
|
||||
No end date means the schedule repeats indefinitely, so every date
|
||||
qualifies.
|
||||
"""
|
||||
return self.end_date is None or d <= self.end_date
|
||||
|
||||
@property
|
||||
def is_expired(self):
|
||||
"""True once the end date has passed.
|
||||
|
||||
Independent of `active`: a schedule can be inactive because it expired
|
||||
or because a manager switched it off, and the list view distinguishes
|
||||
the two. Compare against the *end date* rather than `next_due_date`,
|
||||
which may have been advanced past the boundary by fulfill().
|
||||
"""
|
||||
if self.end_date is None:
|
||||
return False
|
||||
return self.end_date < now_eastern().date()
|
||||
|
||||
def expire_if_past_end_date(self, today=None):
|
||||
"""Deactivate a schedule whose end date has passed. Caller commits.
|
||||
|
||||
Returns True if this call changed anything. Needed because a schedule
|
||||
can reach its end date *without ever being completed* — fulfill() never
|
||||
runs, so the boundary would otherwise be checked nowhere and the cron
|
||||
would keep firing overdue alerts forever. Called from run_reminders().
|
||||
"""
|
||||
today = today or now_eastern().date()
|
||||
if self.active and self.end_date is not None and self.end_date < today:
|
||||
self.active = False
|
||||
return True
|
||||
return False
|
||||
|
||||
def fulfill(self):
|
||||
"""Mark this occurrence complete. One-time schedules deactivate;
|
||||
recurring ones roll their due date forward past today and reset the
|
||||
reminder flags. Caller commits."""
|
||||
reminder flags. A recurring schedule whose next occurrence would fall
|
||||
past its end date deactivates instead. Caller commits."""
|
||||
self.last_completed_at = now_eastern()
|
||||
if self.frequency == 'once':
|
||||
self.active = False
|
||||
return
|
||||
# Recurring: advance until the next due date is in the future.
|
||||
today = now_eastern().date()
|
||||
nxt = self._add_interval(self.next_due_date, self.frequency)
|
||||
nxt = self.next_occurrence_after(self.next_due_date)
|
||||
guard = 0
|
||||
while nxt <= today and guard < 400:
|
||||
nxt = self._add_interval(nxt, self.frequency)
|
||||
nxt = self.next_occurrence_after(nxt)
|
||||
guard += 1
|
||||
self.next_due_date = nxt
|
||||
# Past the manager's boundary: this was the last occurrence. next_due_date
|
||||
# is left at the computed value rather than clamped, so the row still
|
||||
# shows which occurrence it stopped before.
|
||||
if not self.is_within_end_date(nxt):
|
||||
self.active = False
|
||||
return
|
||||
self.advance_notified = False
|
||||
self.due_notified = False
|
||||
self.overdue_notified = False
|
||||
|
||||
+6
-1
@@ -34,7 +34,12 @@ class User(UserMixin, db.Model):
|
||||
set_password_token_expires = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
inspections = db.relationship('Inspection', backref='inspector', lazy='dynamic')
|
||||
# Explicit foreign_keys: inspections now has a SECOND FK to users
|
||||
# (follow_up_requested_by, phase46), so the join is otherwise ambiguous.
|
||||
# This relationship means "inspections I performed" — inspector_id only.
|
||||
inspections = db.relationship('Inspection', backref='inspector',
|
||||
lazy='dynamic',
|
||||
foreign_keys='Inspection.inspector_id')
|
||||
|
||||
# ── Flask-Login integration ────────────────────────────────────────────
|
||||
# Override UserMixin.is_active so that disabled accounts are rejected
|
||||
|
||||
@@ -361,8 +361,10 @@ def index():
|
||||
# ── Scheduled inspections (phase36): upcoming / overdue ──────────────
|
||||
sched_upcoming = []
|
||||
sched_overdue_count = 0
|
||||
sched_open_inspections = {}
|
||||
if not is_customer:
|
||||
from app.models.scheduled_inspection import ScheduledInspection
|
||||
from app.routes.scheduled_inspections import _open_inspection_ids
|
||||
_today = now.date()
|
||||
_sq = ScheduledInspection.query.filter_by(active=True)
|
||||
if is_inspector:
|
||||
@@ -374,11 +376,14 @@ def index():
|
||||
s for s in _all_sched
|
||||
if _today <= s.next_due_date <= _today + timedelta(days=7)
|
||||
][:8]
|
||||
# Offer Continue (not a duplicate Start) where one is already underway.
|
||||
sched_open_inspections = _open_inspection_ids(sched_upcoming)
|
||||
|
||||
return render_template(
|
||||
'dashboard.html',
|
||||
sched_upcoming = sched_upcoming,
|
||||
sched_overdue_count = sched_overdue_count,
|
||||
sched_open_inspections = sched_open_inspections,
|
||||
submitted_this_week = submitted_this_week,
|
||||
completed_today = completed_today,
|
||||
open_issues = open_issues,
|
||||
|
||||
+71
-16
@@ -21,6 +21,7 @@ from app.utils.notifications import notify, notify_customers_for_facility, notif
|
||||
from app.models.notification import (
|
||||
EVENT_INSPECTION_DONE, EVENT_ISSUE_ASSIGNED,
|
||||
EVENT_CUSTOMER_INSPECTION_DONE, EVENT_CUSTOMER_ISSUE_UPDATED,
|
||||
EVENT_FOLLOWUP_REQUESTED,
|
||||
)
|
||||
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
|
||||
@@ -1215,29 +1216,61 @@ def export_pdf(inspection_id):
|
||||
|
||||
@bp.route('/<int:inspection_id>/flag-followup', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def flag_followup(inspection_id):
|
||||
"""Mark an inspection as requiring a follow-up re-inspection."""
|
||||
"""Mark an inspection as requiring a follow-up re-inspection.
|
||||
|
||||
Open to admin/director AND to customers for their own facilities — a client
|
||||
unhappy with a result can ask for a re-inspection directly rather than
|
||||
going through support. Every other role is refused.
|
||||
|
||||
Customers may only *request*: they cannot clear the flag (see
|
||||
clear_followup, still admin/director) nor run the re-inspection itself.
|
||||
"""
|
||||
inspection = db.session.get(Inspection, inspection_id)
|
||||
if inspection is None:
|
||||
abort(404)
|
||||
|
||||
is_customer = current_user.role == 'customer'
|
||||
if is_customer:
|
||||
# Same facility scope as view() — a customer must not be able to reach
|
||||
# another client's inspection with a crafted POST.
|
||||
if inspection.facility_id not in (get_customer_scope(current_user) or []):
|
||||
abort(403)
|
||||
# Nothing to follow up on until the inspection has been submitted.
|
||||
if inspection.status != 'completed':
|
||||
flash('You can only request a follow-up on a completed inspection.', 'warning')
|
||||
return redirect(url_for('inspections.view', inspection_id=inspection_id))
|
||||
# Don't let a repeat request overwrite the note/attribution of a pending
|
||||
# one — the flag is already raised and staff are already on it.
|
||||
if inspection.follow_up_required:
|
||||
flash('A follow-up has already been requested for this inspection.', 'info')
|
||||
return redirect(url_for('inspections.view', inspection_id=inspection_id))
|
||||
elif current_user.role not in ('admin', 'director'):
|
||||
abort(403)
|
||||
|
||||
note = request.form.get('follow_up_note', '').strip() or None
|
||||
|
||||
inspection.follow_up_required = True
|
||||
inspection.follow_up_note = note
|
||||
inspection.follow_up_required = True
|
||||
inspection.follow_up_note = note
|
||||
inspection.follow_up_requested_by = current_user.id
|
||||
inspection.follow_up_requested_at = now_eastern()
|
||||
db.session.commit()
|
||||
|
||||
# Notify the original inspector so they see it on the iPad
|
||||
note_suffix = f' Note: {note}' if note else ''
|
||||
who = (f'The customer ({current_user.display_name})' if is_customer
|
||||
else current_user.display_name)
|
||||
body = (
|
||||
f'{who} has requested a follow-up re-inspection '
|
||||
f'of "{inspection.template.name}" at {inspection.facility.name}.{note_suffix}'
|
||||
)
|
||||
|
||||
# Notify the original inspector so they see it on the iPad.
|
||||
inspector = db.session.get(User, inspection.inspector_id)
|
||||
if inspector and inspector.id != current_user.id:
|
||||
note_suffix = f' Note: {note}' if note else ''
|
||||
notify(
|
||||
recipient = inspector,
|
||||
title = f'Follow-Up Required: Inspection #{inspection_id}',
|
||||
body = (
|
||||
f'{current_user.username} has requested a follow-up re-inspection '
|
||||
f'of "{inspection.template.name}" at {inspection.facility.name}.{note_suffix}'
|
||||
),
|
||||
body = body,
|
||||
link = url_for('inspections.view', inspection_id=inspection_id),
|
||||
inspection_id = inspection_id,
|
||||
event_type = EVENT_INSPECTION_DONE,
|
||||
@@ -1245,14 +1278,34 @@ def flag_followup(inspection_id):
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
# Route to the staff who action follow-ups. Going through notify_by_matrix
|
||||
# rather than notifying managers directly keeps recipients admin-configurable
|
||||
# and lets per-contract recipients fire too (rule 73). This matters most for
|
||||
# a customer request: without it only the inspector would hear about it and
|
||||
# nobody would be accountable for scheduling the re-inspection.
|
||||
notify_by_matrix(
|
||||
event_type = EVENT_FOLLOWUP_REQUESTED,
|
||||
title = f'Follow-Up Requested: Inspection #{inspection_id}',
|
||||
body = body,
|
||||
link = url_for('inspections.view', inspection_id=inspection_id),
|
||||
inspection_id = inspection_id,
|
||||
facility_id = inspection.facility_id,
|
||||
exclude_user_ids = {current_user.id,
|
||||
inspector.id if inspector else None} - {None},
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
current_app.logger.info(
|
||||
'INSPECTION FOLLOW-UP FLAGGED | id=%s | by=%s | note=%r',
|
||||
inspection_id, current_user.username, note,
|
||||
'INSPECTION FOLLOW-UP FLAGGED | id=%s | by=%s (%s) | note=%r',
|
||||
inspection_id, current_user.username, current_user.role, note,
|
||||
)
|
||||
log_action(ACTION_UPDATE, 'Inspection', inspection_id,
|
||||
f'{inspection.template.name} @ {inspection.facility.name}',
|
||||
f'follow_up_required=True; note={note!r}')
|
||||
flash('Follow-up inspection required flag set.', 'warning')
|
||||
f'follow_up_required=True; by_role={current_user.role}; note={note!r}')
|
||||
if is_customer:
|
||||
flash('Follow-up re-inspection requested. The team has been notified.', 'success')
|
||||
else:
|
||||
flash('Follow-up inspection required flag set.', 'warning')
|
||||
return redirect(url_for('inspections.view', inspection_id=inspection_id))
|
||||
|
||||
|
||||
@@ -1264,8 +1317,10 @@ def clear_followup(inspection_id):
|
||||
inspection = db.session.get(Inspection, inspection_id)
|
||||
if inspection is None:
|
||||
abort(404)
|
||||
inspection.follow_up_required = False
|
||||
inspection.follow_up_note = None
|
||||
inspection.follow_up_required = False
|
||||
inspection.follow_up_note = None
|
||||
inspection.follow_up_requested_by = None
|
||||
inspection.follow_up_requested_at = None
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'Inspection', inspection_id,
|
||||
f'{inspection.template.name} @ {inspection.facility.name}',
|
||||
|
||||
@@ -22,7 +22,8 @@ from flask import (Blueprint, render_template, redirect, url_for, flash,
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from app import db
|
||||
from app.models.scheduled_inspection import ScheduledInspection
|
||||
from app.models.scheduled_inspection import (ScheduledInspection,
|
||||
MONTH_MODE_DAY, MONTH_MODE_NTH)
|
||||
from app.models.facility import Facility
|
||||
from app.models.inspection import Inspection, InspectionTemplate
|
||||
from app.models.project import Project
|
||||
@@ -79,6 +80,72 @@ def _notify_assignee(sched, reassigned=False):
|
||||
)
|
||||
|
||||
|
||||
def _apply_recurrence(sched, form):
|
||||
"""Copy the recurrence block for the chosen frequency onto *sched* and
|
||||
clear the blocks that no longer apply, then snap next_due_date onto the
|
||||
rule. Keeping the unused columns NULL means `recurrence_label` and the
|
||||
date math never read stale settings after a frequency change."""
|
||||
sched.frequency = form.frequency.data
|
||||
|
||||
if sched.frequency == 'weekly':
|
||||
sched.set_weekdays(form.weekdays.data)
|
||||
else:
|
||||
sched.weekdays = None
|
||||
|
||||
if sched.frequency == 'monthly':
|
||||
sched.month_mode = form.month_mode.data or MONTH_MODE_DAY
|
||||
if sched.month_mode == MONTH_MODE_NTH:
|
||||
sched.day_of_month = None
|
||||
sched.nth_week = form.nth_week.data
|
||||
sched.nth_weekday = form.nth_weekday.data
|
||||
else:
|
||||
sched.day_of_month = form.day_of_month.data
|
||||
sched.nth_week = None
|
||||
sched.nth_weekday = None
|
||||
else:
|
||||
sched.month_mode = sched.day_of_month = None
|
||||
sched.nth_week = sched.nth_weekday = None
|
||||
|
||||
# End date (phase44) — a boundary, not a cadence setting. A one-time
|
||||
# schedule has none: it ends by deactivating when it is completed.
|
||||
sched.end_date = form.end_date.data if sched.frequency != 'once' else None
|
||||
|
||||
# Snap the picked date forward onto the first matching occurrence.
|
||||
sched.next_due_date = sched.align_due_date(form.next_due_date.data)
|
||||
|
||||
|
||||
def _reject_if_past_end_date(sched, form):
|
||||
"""True (and a form error set) if the aligned first occurrence falls past
|
||||
the end date.
|
||||
|
||||
The form already rejects an end date earlier than the *picked* due date, but
|
||||
align_due_date() can push that date forward onto the recurrence rule — pick
|
||||
a Tuesday for a Mon/Wed/Fri schedule and the first occurrence is Wednesday.
|
||||
Without this check that combination would save as active with no occurrence
|
||||
it is ever allowed to run.
|
||||
"""
|
||||
if sched.is_within_end_date(sched.next_due_date):
|
||||
return False
|
||||
form.end_date.errors.append(
|
||||
f'With this recurrence the first occurrence falls on '
|
||||
f'{sched.next_due_date:%b %d, %Y}, after the end date.')
|
||||
return True
|
||||
|
||||
|
||||
def _open_inspection_ids(schedules):
|
||||
"""{schedule_id: inspection_id} for schedules with an inspection already
|
||||
in progress, so the UI offers Continue instead of a duplicate Start."""
|
||||
ids = [s.id for s in schedules if s.id]
|
||||
if not ids:
|
||||
return {}
|
||||
rows = (Inspection.query
|
||||
.filter(Inspection.scheduled_inspection_id.in_(ids),
|
||||
Inspection.status == 'in_progress')
|
||||
.order_by(Inspection.id.desc())
|
||||
.all())
|
||||
return {r.scheduled_inspection_id: r.id for r in rows}
|
||||
|
||||
|
||||
def _selected_project_id(form):
|
||||
"""Contract of the submitted facility (for restoring the selector on
|
||||
re-render), or None."""
|
||||
@@ -110,7 +177,8 @@ def index():
|
||||
).all()
|
||||
|
||||
return render_template('scheduled_inspections/list.html',
|
||||
schedules=schedules, today=today)
|
||||
schedules=schedules, today=today,
|
||||
open_inspections=_open_inspection_ids(schedules))
|
||||
|
||||
|
||||
# ── Create ──────────────────────────────────────────────────────────────────
|
||||
@@ -121,6 +189,10 @@ def index():
|
||||
def create():
|
||||
form = ScheduledInspectionForm()
|
||||
_populate_choices(form)
|
||||
# On a new schedule this date IS the start; on edit it is whatever the next
|
||||
# occurrence happens to be. One field, two meanings — so the label follows
|
||||
# the context instead of saying both at once.
|
||||
form.next_due_date.label.text = 'Start Date'
|
||||
if not form.next_due_date.data:
|
||||
form.next_due_date.data = now_eastern().date()
|
||||
|
||||
@@ -129,17 +201,25 @@ def create():
|
||||
facility_id = form.facility_id.data,
|
||||
template_id = form.template_id.data,
|
||||
inspector_id = form.inspector_id.data,
|
||||
frequency = form.frequency.data,
|
||||
next_due_date = form.next_due_date.data,
|
||||
notes = (form.notes.data or '').strip() or None,
|
||||
active = form.active.data,
|
||||
created_by = current_user.id,
|
||||
)
|
||||
_apply_recurrence(sched, form)
|
||||
if _reject_if_past_end_date(sched, form):
|
||||
# sched was never added to the session — nothing to roll back.
|
||||
return render_template('scheduled_inspections/form.html',
|
||||
form=form, title='New Scheduled Inspection',
|
||||
projects=_active_contracts(),
|
||||
selected_project_id=_selected_project_id(form))
|
||||
db.session.add(sched)
|
||||
db.session.commit()
|
||||
log_action(ACTION_CREATE, 'ScheduledInspection', sched.id,
|
||||
f'{sched.template.name} @ {sched.facility.name}',
|
||||
f'freq={sched.frequency}; due={sched.next_due_date}; inspector={sched.inspector_id}')
|
||||
f'freq={sched.recurrence_label}; due={sched.next_due_date}; '
|
||||
f'end={sched.end_date or "—"}; '
|
||||
f'inspector={sched.inspector_id}')
|
||||
logger.info('SCHED INSP | create | by=%s | id=%s', current_user.username, sched.id)
|
||||
|
||||
# Notify the assigned inspector immediately.
|
||||
@@ -166,20 +246,36 @@ def edit(schedule_id):
|
||||
abort(404)
|
||||
form = ScheduledInspectionForm(obj=sched)
|
||||
_populate_choices(form)
|
||||
form.next_due_date.label.text = 'Next Due Date'
|
||||
if request.method == 'GET':
|
||||
# obj= copies the raw CSV column into a multi-select field; hand it the
|
||||
# parsed int list instead so the checkboxes pre-tick correctly.
|
||||
form.weekdays.data = sched.weekday_list
|
||||
form.month_mode.data = sched.month_mode or MONTH_MODE_DAY
|
||||
|
||||
if form.validate_on_submit():
|
||||
old_inspector_id = sched.inspector_id
|
||||
sched.facility_id = form.facility_id.data
|
||||
sched.template_id = form.template_id.data
|
||||
sched.inspector_id = form.inspector_id.data
|
||||
sched.frequency = form.frequency.data
|
||||
sched.next_due_date = form.next_due_date.data
|
||||
sched.notes = (form.notes.data or '').strip() or None
|
||||
sched.active = form.active.data
|
||||
_apply_recurrence(sched, form)
|
||||
if _reject_if_past_end_date(sched, form):
|
||||
# sched is a persistent object and has already been mutated — discard
|
||||
# those pending changes before re-rendering so nothing leaks out on
|
||||
# the next flush.
|
||||
db.session.rollback()
|
||||
return render_template('scheduled_inspections/form.html',
|
||||
form=form, title='Edit Scheduled Inspection',
|
||||
schedule=sched, projects=_active_contracts(),
|
||||
selected_project_id=_selected_project_id(form))
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'ScheduledInspection', sched.id,
|
||||
f'{sched.template.name} @ {sched.facility.name}',
|
||||
f'freq={sched.frequency}; due={sched.next_due_date}; active={sched.active}')
|
||||
f'freq={sched.recurrence_label}; due={sched.next_due_date}; '
|
||||
f'end={sched.end_date or "—"}; '
|
||||
f'active={sched.active}')
|
||||
|
||||
# Notify the inspector if the assignment changed to them.
|
||||
if sched.active and sched.inspector_id and sched.inspector_id != old_inspector_id:
|
||||
@@ -242,6 +338,16 @@ def start(schedule_id):
|
||||
flash('The template for this schedule has no form fields yet.', 'warning')
|
||||
return redirect(url_for('scheduled_inspections.index'))
|
||||
|
||||
# Already started but not submitted? Resume it rather than opening a second
|
||||
# inspection against the same occurrence.
|
||||
existing = (Inspection.query
|
||||
.filter_by(scheduled_inspection_id=sched.id, status='in_progress')
|
||||
.order_by(Inspection.id.desc())
|
||||
.first())
|
||||
if existing is not None:
|
||||
flash('Resuming the inspection you already started for this schedule.', 'info')
|
||||
return redirect(url_for('inspections.execute', inspection_id=existing.id))
|
||||
|
||||
inspection = Inspection(
|
||||
template_id = sched.template_id,
|
||||
facility_id = sched.facility_id,
|
||||
@@ -250,6 +356,12 @@ def start(schedule_id):
|
||||
inspection_date = now_eastern(),
|
||||
status = 'in_progress',
|
||||
scheduled_inspection_id = sched.id,
|
||||
# phase45 — a schedule created by "Schedule Follow-up" carries the
|
||||
# inspection it is a follow-up of. Inheriting it here is what makes the
|
||||
# run a real linked re-inspection: execute() pre-fills from the parent
|
||||
# and submit clears the parent's follow_up_required. NULL for ordinary
|
||||
# schedules, which is every pre-phase45 row.
|
||||
parent_inspection_id = sched.parent_inspection_id,
|
||||
)
|
||||
db.session.add(inspection)
|
||||
db.session.commit()
|
||||
@@ -271,10 +383,23 @@ def run_reminders():
|
||||
abort(403)
|
||||
|
||||
today = now_eastern().date()
|
||||
sent = {'advance': 0, 'due': 0, 'overdue': 0}
|
||||
sent = {'advance': 0, 'due': 0, 'overdue': 0, 'expired': 0}
|
||||
|
||||
schedules = ScheduledInspection.query.filter_by(active=True).all()
|
||||
|
||||
# Expire schedules past their end date BEFORE any reminder work (phase44).
|
||||
# fulfill() closes out a schedule that reaches its boundary by being
|
||||
# completed; this covers the one that reaches it without ever being done —
|
||||
# otherwise it stays active and re-alerts as overdue indefinitely.
|
||||
live = []
|
||||
for s in schedules:
|
||||
if s.expire_if_past_end_date(today):
|
||||
sent['expired'] += 1
|
||||
logger.info('SCHED INSP | expired | id=%s | end=%s', s.id, s.end_date)
|
||||
else:
|
||||
live.append(s)
|
||||
schedules = live
|
||||
|
||||
# Cache admin/director recipients for overdue alerts
|
||||
managers = User.query.filter(
|
||||
User.role.in_(['admin', 'director']), User.active == True # noqa: E712
|
||||
@@ -333,6 +458,6 @@ def run_reminders():
|
||||
sent['overdue'] += 1
|
||||
|
||||
db.session.commit()
|
||||
logger.info('SCHED INSP | reminders | advance=%s due=%s overdue=%s',
|
||||
sent['advance'], sent['due'], sent['overdue'])
|
||||
logger.info('SCHED INSP | reminders | advance=%s due=%s overdue=%s expired=%s',
|
||||
sent['advance'], sent['due'], sent['overdue'], sent['expired'])
|
||||
return {'ok': True, 'sent': sent}, 200
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<tr><th>Facility</th><th>Template</th><th>Inspector</th><th>Due</th><th></th></tr>
|
||||
<tr><th>Facility</th><th>Template</th><th>Inspector</th><th>Repeats</th><th>Due</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in sched_upcoming %}
|
||||
@@ -56,13 +56,21 @@
|
||||
<td>{{ s.facility.name if s.facility else '—' }}</td>
|
||||
<td class="small">{{ s.template.name if s.template else '—' }}</td>
|
||||
<td class="small">{{ s.inspector.display_name if s.inspector else '—' }}</td>
|
||||
<td class="small text-muted">{{ s.recurrence_label }}</td>
|
||||
<td class="small">{{ s.next_due_date.strftime('%b %d') }}</td>
|
||||
<td class="text-end">
|
||||
{# Start is shown only to the assignee — the inspection is theirs to do. #}
|
||||
{% if s.inspector_id and s.inspector_id == current_user.id %}
|
||||
{% set open_id = sched_open_inspections.get(s.id) %}
|
||||
{% if open_id %}
|
||||
<a href="{{ url_for('inspections.execute', inspection_id=open_id) }}"
|
||||
class="btn btn-sm btn-warning py-0" title="You already started this — resume it">
|
||||
<i class="bi bi-pencil-square"></i> Continue</a>
|
||||
{% else %}
|
||||
<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>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -263,6 +263,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Instructions from the schedule (phase36) ──
|
||||
Only rendered when this inspection was started from a ScheduledInspection
|
||||
that carries instructions. `scheduled_inspection` is NULL for ad-hoc work
|
||||
and for schedules deleted after the inspection was started, so both the
|
||||
relationship and the text are guarded. #}
|
||||
{% if inspection.scheduled_inspection and inspection.scheduled_inspection.notes %}
|
||||
<div style="background:#eef2ff;border:1px solid #c7d2fe;border-left:4px solid #6366f1;
|
||||
padding:.9rem 1.1rem;margin-top:.85rem;border-radius:8px;">
|
||||
<div class="fw-semibold mb-1" style="color:#3730a3;font-size:.9rem;">
|
||||
<i class="bi bi-info-circle-fill"></i> Instructions for this inspection
|
||||
</div>
|
||||
<div style="white-space:pre-wrap;color:#1e1b4b;font-size:.9rem;">{{ inspection.scheduled_inspection.notes }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Form body ── #}
|
||||
<div class="insp-body">
|
||||
{% if form_fields %}
|
||||
|
||||
@@ -356,6 +356,16 @@
|
||||
<i class="bi bi-arrow-repeat"></i> Re-inspect
|
||||
</a>
|
||||
{% endif %}
|
||||
{# Customers may REQUEST a follow-up on their own completed inspections;
|
||||
only admin/director can clear one. #}
|
||||
{% if current_user.role == 'customer' and inspection.status == 'completed'
|
||||
and not inspection.follow_up_required %}
|
||||
<button type="button" class="btn btn-sm btn-outline-warning"
|
||||
data-bs-toggle="modal" data-bs-target="#followupModal"
|
||||
title="Ask the team to re-inspect this facility">
|
||||
<i class="bi bi-flag"></i> Request Follow-up
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin','director'] %}
|
||||
{% if not inspection.follow_up_required %}
|
||||
<button type="button" class="btn btn-sm btn-outline-warning"
|
||||
@@ -388,13 +398,27 @@
|
||||
<i class="bi bi-flag-fill mt-1"></i>
|
||||
<div>
|
||||
<strong>Follow-up Inspection Required</strong>
|
||||
{% if inspection.follow_up_requester %}
|
||||
<span class="badge {{ 'bg-info text-dark' if inspection.follow_up_requester.role == 'customer' else 'bg-secondary' }} ms-1">
|
||||
{{ 'Requested by customer' if inspection.follow_up_requester.role == 'customer' else 'Requested by staff' }}:
|
||||
{{ inspection.follow_up_requester.display_name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if inspection.follow_up_requested_at %}
|
||||
<span class="small text-muted ms-1">{{ inspection.follow_up_requested_at.strftime('%b %d, %Y %I:%M %p') }}</span>
|
||||
{% endif %}
|
||||
{% if inspection.follow_up_note %}<br><span class="small">{{ inspection.follow_up_note }}</span>{% endif %}
|
||||
{# Re-inspection is staff work — reinspect() already refuses customers. #}
|
||||
{% if current_user.role != 'customer' %}
|
||||
<div class="mt-2">
|
||||
<a href="{{ url_for('inspections.reinspect', inspection_id=inspection.id) }}"
|
||||
class="btn btn-sm btn-warning">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Start Re-inspection
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="small mt-1">The team has been notified and will schedule the re-inspection.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -886,19 +910,31 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMedia()
|
||||
<form method="POST" action="{{ url_for('inspections.flag_followup', inspection_id=inspection.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-content">
|
||||
{% set is_cust = current_user.role == 'customer' %}
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-flag me-2"></i>Flag Follow-up Required</h5>
|
||||
<h5 class="modal-title">
|
||||
<i class="bi bi-flag me-2"></i>{{ 'Request a Follow-up Inspection' if is_cust else 'Flag Follow-up Required' }}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="form-label fw-semibold">Reason / Notes <span class="text-muted small">(optional)</span></label>
|
||||
{% if is_cust %}
|
||||
<p class="small text-muted">
|
||||
Ask the team to re-inspect this facility. Your request is sent to the
|
||||
inspector and management right away.
|
||||
</p>
|
||||
{% endif %}
|
||||
<label class="form-label fw-semibold">
|
||||
{{ 'What still needs attention?' if is_cust else 'Reason / Notes' }}
|
||||
<span class="text-muted small">(optional)</span>
|
||||
</label>
|
||||
<textarea name="follow_up_note" class="form-control" rows="3"
|
||||
placeholder="Describe what needs to be addressed in the follow-up inspection…"></textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-warning">
|
||||
<i class="bi bi-flag me-1"></i>Flag Follow-up
|
||||
<i class="bi bi-flag me-1"></i>{{ 'Send Request' if is_cust else 'Flag Follow-up' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,12 +49,93 @@
|
||||
{{ form.next_due_date.label(class="form-label fw-semibold") }}
|
||||
{{ form.next_due_date(class="form-control", type="date") }}
|
||||
{% for e in form.next_due_date.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
<div class="form-text">
|
||||
Snapped forward to the first matching day.
|
||||
Advances automatically after each completed inspection.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── End date (phase44) ──
|
||||
Hidden for one-time schedules, which end by deactivating when
|
||||
completed. syncFrequency() toggles it; the route forces the column
|
||||
to NULL when frequency == 'once', so a stale DOM value cannot
|
||||
survive a frequency change. #}
|
||||
<div class="row" id="end_date_row" hidden>
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.end_date.label(class="form-label fw-semibold") }}
|
||||
{{ form.end_date(class="form-control", type="date") }}
|
||||
{% for e in form.end_date.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
<div class="form-text">
|
||||
Optional. The last date this schedule may run — leave blank to repeat indefinitely.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Weekly: which days of the week ──────────────────────────── #}
|
||||
<div class="mb-3 p-3 rounded bg-light border" id="weekly_block" hidden>
|
||||
<label class="form-label fw-semibold d-block">{{ form.weekdays.label.text }}</label>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
{% for value, label in form.weekdays.choices %}
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="weekdays"
|
||||
id="weekday_{{ value }}" value="{{ value }}"
|
||||
{% if form.weekdays.data and value in form.weekdays.data %}checked{% endif %}>
|
||||
<label class="form-check-label" for="weekday_{{ value }}">{{ label[:3] }}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% for e in form.weekdays.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
<div class="form-text mb-0">
|
||||
Pick every day the inspection recurs — e.g. Mon, Wed, Fri gives three
|
||||
inspections a week. The due date rolls to the next selected day each
|
||||
time one is submitted.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Monthly: day-of-month OR nth weekday ────────────────────── #}
|
||||
<div class="mb-3 p-3 rounded bg-light border" id="monthly_block" hidden>
|
||||
<label class="form-label fw-semibold d-block">{{ form.month_mode.label.text }}</label>
|
||||
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="month_mode"
|
||||
id="month_mode_day" value="day_of_month"
|
||||
{% if form.month_mode.data != 'nth_weekday' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="month_mode_day">On a day of the month</label>
|
||||
</div>
|
||||
<div class="ms-4 mb-2" id="dom_row">
|
||||
<div class="input-group input-group-sm" style="max-width:16rem;">
|
||||
<span class="input-group-text">Day</span>
|
||||
{{ form.day_of_month(class="form-control", type="number", min=1, max=31,
|
||||
placeholder="15") }}
|
||||
</div>
|
||||
{% for e in form.day_of_month.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
<div class="form-text mb-0">Months without that day use their last day.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="month_mode"
|
||||
id="month_mode_nth" value="nth_weekday"
|
||||
{% if form.month_mode.data == 'nth_weekday' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="month_mode_nth">On a weekday of the month</label>
|
||||
</div>
|
||||
<div class="ms-4" id="nth_row">
|
||||
<div class="d-flex gap-2 flex-wrap" style="max-width:24rem;">
|
||||
{{ form.nth_week(class="form-select form-select-sm", style="max-width:7rem;") }}
|
||||
{{ form.nth_weekday(class="form-select form-select-sm", style="max-width:11rem;") }}
|
||||
</div>
|
||||
{% for e in form.nth_week.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
<div class="form-text mb-0">e.g. the 2nd Tuesday of every month.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.notes.label(class="form-label fw-semibold") }}
|
||||
{{ form.notes(class="form-control", rows=2, placeholder="Optional instructions for the inspector…") }}
|
||||
{{ form.notes(class="form-control", rows=3, placeholder="e.g. Front lobby carpet needs extra attention. Check loading dock after 3 PM — key is at the front desk.") }}
|
||||
<div class="form-text">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
Shown to the assigned inspector when they open this inspection, on the web and on the iPad.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
@@ -129,5 +210,43 @@
|
||||
setPlaceholder();
|
||||
}
|
||||
}());
|
||||
|
||||
// Recurrence blocks: only the one matching the chosen frequency is shown.
|
||||
// The server clears the columns for the hidden blocks on save, so stale values
|
||||
// left in the DOM never take effect.
|
||||
(function () {
|
||||
'use strict';
|
||||
var freq = document.getElementById('frequency') ||
|
||||
document.querySelector('[name="frequency"]');
|
||||
var weekly = document.getElementById('weekly_block');
|
||||
var monthly = document.getElementById('monthly_block');
|
||||
var endRow = document.getElementById('end_date_row');
|
||||
if (!freq || !weekly || !monthly) { return; }
|
||||
|
||||
var domRadio = document.getElementById('month_mode_day');
|
||||
var nthRadio = document.getElementById('month_mode_nth');
|
||||
var domRow = document.getElementById('dom_row');
|
||||
var nthRow = document.getElementById('nth_row');
|
||||
|
||||
function syncMonthMode() {
|
||||
var useNth = nthRadio && nthRadio.checked;
|
||||
domRow.style.opacity = useNth ? '.45' : '1';
|
||||
nthRow.style.opacity = useNth ? '1' : '.45';
|
||||
}
|
||||
|
||||
function syncFrequency() {
|
||||
weekly.hidden = freq.value !== 'weekly';
|
||||
monthly.hidden = freq.value !== 'monthly';
|
||||
// End date is a recurring-only concept.
|
||||
if (endRow) { endRow.hidden = freq.value === 'once'; }
|
||||
syncMonthMode();
|
||||
}
|
||||
|
||||
freq.addEventListener('change', syncFrequency);
|
||||
[domRadio, nthRadio].forEach(function (r) {
|
||||
if (r) { r.addEventListener('change', syncMonthMode); }
|
||||
});
|
||||
syncFrequency();
|
||||
}());
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<th>Inspector</th>
|
||||
<th>Frequency</th>
|
||||
<th>Next Due</th>
|
||||
<th>Ends</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end"></th>
|
||||
</tr>
|
||||
@@ -43,7 +44,7 @@
|
||||
<td><strong>{{ s.facility.name if s.facility else '—' }}</strong></td>
|
||||
<td>{{ s.template.name if s.template else '—' }}</td>
|
||||
<td>{{ s.inspector.display_name if s.inspector else '— Unassigned —' }}</td>
|
||||
<td><span class="badge bg-secondary">{{ s.frequency_label }}</span></td>
|
||||
<td><span class="badge bg-secondary">{{ s.recurrence_label }}</span></td>
|
||||
<td>
|
||||
{{ s.next_due_date.strftime('%b %d, %Y') }}
|
||||
{% if overdue %}
|
||||
@@ -52,9 +53,22 @@
|
||||
<span class="badge bg-warning text-dark ms-1">Due soon</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{% if s.frequency == 'once' %}
|
||||
<span class="text-muted">—</span>
|
||||
{% elif s.end_date %}
|
||||
{{ s.end_date.strftime('%b %d, %Y') }}
|
||||
{% else %}
|
||||
<span class="text-muted" title="Repeats indefinitely">No end</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{# Three states, not two: "Ended" distinguishes a schedule that ran
|
||||
its course from one a manager switched off. #}
|
||||
{% if s.active %}
|
||||
<span class="badge bg-success">Active</span>
|
||||
{% elif s.is_expired %}
|
||||
<span class="badge bg-dark" title="Passed its end date">Ended</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Inactive</span>
|
||||
{% endif %}
|
||||
@@ -62,11 +76,19 @@
|
||||
<td class="text-end text-nowrap">
|
||||
{# Start is shown only to the assignee — the inspection is theirs to do. #}
|
||||
{% if s.active and s.inspector_id and s.inspector_id == current_user.id %}
|
||||
{% set open_id = open_inspections.get(s.id) %}
|
||||
{% if open_id %}
|
||||
<a href="{{ url_for('inspections.execute', inspection_id=open_id) }}"
|
||||
class="btn btn-sm btn-warning" title="You already started this — resume it">
|
||||
<i class="bi bi-pencil-square"></i> Continue
|
||||
</a>
|
||||
{% else %}
|
||||
<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 %}
|
||||
{% endif %}
|
||||
{% 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>
|
||||
|
||||
+68
-3
@@ -2,7 +2,7 @@ from flask_wtf import FlaskForm
|
||||
from flask_wtf.file import FileField, FileAllowed, MultipleFileField
|
||||
from wtforms import (StringField, PasswordField, SelectField, TextAreaField,
|
||||
DecimalField, BooleanField, IntegerField, HiddenField,
|
||||
RadioField, DateField)
|
||||
RadioField, DateField, SelectMultipleField)
|
||||
from wtforms.validators import (DataRequired, Email, Length, EqualTo,
|
||||
Optional, NumberRange, ValidationError)
|
||||
from app.models.user import User
|
||||
@@ -338,10 +338,75 @@ class ScheduledInspectionForm(FlaskForm):
|
||||
('once', 'One-time'), ('daily', 'Daily'),
|
||||
('weekly', 'Weekly'), ('monthly', 'Monthly'),
|
||||
], validators=[DataRequired()])
|
||||
next_due_date = DateField('Due Date', validators=[DataRequired()])
|
||||
notes = TextAreaField('Notes', validators=[Optional(), Length(max=1000)])
|
||||
next_due_date = DateField('Start / Due Date', validators=[DataRequired()])
|
||||
# Label is overridden per context in routes/scheduled_inspections.py:
|
||||
# "Start Date" when creating, "Next Due Date" when editing. The default
|
||||
# above is only a fallback.
|
||||
end_date = DateField('End Date', validators=[Optional()])
|
||||
# UI label only. The field name, the ScheduledInspection.notes attribute and
|
||||
# the scheduled_inspections.notes column all stay `notes` — renaming any of
|
||||
# them would break the API payload key the iPad decodes.
|
||||
notes = TextAreaField('Instructions', validators=[Optional(), Length(max=1000)])
|
||||
active = BooleanField('Active', default=True)
|
||||
|
||||
# ── Recurrence detail (phase43) ──────────────────────────────────────────
|
||||
# Only the block matching `frequency` is required; the rest is ignored and
|
||||
# cleared on save. Shown/hidden client-side, enforced in validate() below.
|
||||
weekdays = SelectMultipleField(
|
||||
'Days of the Week', coerce=int, validators=[Optional()],
|
||||
choices=[(i, n) for i, n in enumerate(
|
||||
['Monday', 'Tuesday', 'Wednesday', 'Thursday',
|
||||
'Friday', 'Saturday', 'Sunday'])],
|
||||
)
|
||||
month_mode = SelectField('Monthly Rule', validators=[Optional()], choices=[
|
||||
('day_of_month', 'On a day of the month'),
|
||||
('nth_weekday', 'On a weekday of the month'),
|
||||
], default='day_of_month')
|
||||
day_of_month = IntegerField(
|
||||
'Day of Month', validators=[Optional(), NumberRange(min=1, max=31)])
|
||||
nth_week = SelectField('Week', coerce=int, validators=[Optional()], choices=[
|
||||
(1, '1st'), (2, '2nd'), (3, '3rd'), (4, '4th'), (5, '5th'), (-1, 'Last'),
|
||||
], default=1)
|
||||
nth_weekday = SelectField(
|
||||
'Weekday', coerce=int, validators=[Optional()],
|
||||
choices=[(i, n) for i, n in enumerate(
|
||||
['Monday', 'Tuesday', 'Wednesday', 'Thursday',
|
||||
'Friday', 'Saturday', 'Sunday'])],
|
||||
default=0,
|
||||
)
|
||||
|
||||
def validate(self, extra_validators=None):
|
||||
"""Conditionally require the recurrence block for the chosen frequency."""
|
||||
if not super().validate(extra_validators):
|
||||
return False
|
||||
ok = True
|
||||
if self.frequency.data == 'weekly' and not self.weekdays.data:
|
||||
self.weekdays.errors.append('Pick at least one day of the week.')
|
||||
ok = False
|
||||
elif self.frequency.data == 'monthly':
|
||||
if self.month_mode.data == 'nth_weekday':
|
||||
if not self.nth_week.data or self.nth_weekday.data is None:
|
||||
self.nth_week.errors.append('Choose which weekday of the month.')
|
||||
ok = False
|
||||
elif not self.day_of_month.data:
|
||||
self.day_of_month.errors.append('Enter a day of the month (1–31).')
|
||||
ok = False
|
||||
|
||||
# End date (phase44). Only meaningful for recurring schedules — a
|
||||
# one-time schedule ends by deactivating when it is completed. Rejecting
|
||||
# an end date before the due date here is what makes the "already past
|
||||
# its boundary on save" case unreachable in the routes.
|
||||
if self.end_date.data:
|
||||
if self.frequency.data == 'once':
|
||||
self.end_date.errors.append(
|
||||
'A one-time schedule has no end date — it closes when completed.')
|
||||
ok = False
|
||||
elif self.next_due_date.data and self.end_date.data < self.next_due_date.data:
|
||||
self.end_date.errors.append(
|
||||
'End date must be on or after the due date.')
|
||||
ok = False
|
||||
return ok
|
||||
|
||||
|
||||
# ── Support Knowledge Base (phase38) ─────────────────────────────────────────
|
||||
|
||||
|
||||
+21
-17
@@ -216,40 +216,44 @@ def _draw_overlay(img, lines):
|
||||
width, height = img.size
|
||||
# Scale everything off the short edge so portrait and landscape match.
|
||||
base = min(width, height)
|
||||
font_size = max(14, int(base * 0.035))
|
||||
pad = max(8, int(base * 0.018))
|
||||
font_size = max(11, int(base * 0.020))
|
||||
pad = max(6, int(base * 0.012))
|
||||
font = _load_font(font_size)
|
||||
|
||||
draw = ImageDraw.Draw(img)
|
||||
measure = ImageDraw.Draw(img)
|
||||
|
||||
# Measure the block.
|
||||
heights, widths = [], []
|
||||
for line in lines:
|
||||
box = draw.textbbox((0, 0), line, font=font)
|
||||
box = measure.textbbox((0, 0), line, font=font)
|
||||
widths.append(box[2] - box[0])
|
||||
heights.append(box[3] - box[1])
|
||||
line_gap = max(2, int(font_size * 0.25))
|
||||
text_h = sum(heights) + line_gap * (len(lines) - 1)
|
||||
bar_h = text_h + pad * 2
|
||||
|
||||
# Translucent black bar, composited so it works on RGB too.
|
||||
bar = Image.new('RGBA', (width, bar_h), (0, 0, 0, 150))
|
||||
# Draw the whole overlay on a transparent layer so both the bar AND the
|
||||
# text carry alpha, then composite once. Keeps the photo readable through
|
||||
# the stamp instead of masking it behind a solid strip.
|
||||
layer = Image.new('RGBA', (width, bar_h), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(layer)
|
||||
draw.rectangle((0, 0, width, bar_h), fill=(0, 0, 0, 80))
|
||||
|
||||
y = pad
|
||||
for line, h in zip(lines, heights):
|
||||
# Faint dark outline still keeps the text legible over a bright photo.
|
||||
for dx, dy in ((-1, 0), (1, 0), (0, -1), (0, 1)):
|
||||
draw.text((pad + dx, y + dy), line, font=font, fill=(0, 0, 0, 90))
|
||||
draw.text((pad, y), line, font=font, fill=(255, 255, 255, 165))
|
||||
y += h + line_gap
|
||||
|
||||
if img.mode == 'RGBA':
|
||||
img.alpha_composite(bar, (0, height - bar_h))
|
||||
img.alpha_composite(layer, (0, height - bar_h))
|
||||
else:
|
||||
img.paste(Image.alpha_composite(
|
||||
img.crop((0, height - bar_h, width, height)).convert('RGBA'), bar
|
||||
img.crop((0, height - bar_h, width, height)).convert('RGBA'), layer
|
||||
).convert('RGB'), (0, height - bar_h))
|
||||
|
||||
draw = ImageDraw.Draw(img)
|
||||
y = height - bar_h + pad
|
||||
for line, h in zip(lines, heights):
|
||||
# Thin dark outline keeps the text legible over a bright photo.
|
||||
for dx, dy in ((-1, 0), (1, 0), (0, -1), (0, 1)):
|
||||
draw.text((pad + dx, y + dy), line, font=font, fill=(0, 0, 0))
|
||||
draw.text((pad, y), line, font=font, fill=(255, 255, 255))
|
||||
y += h + line_gap
|
||||
|
||||
return img
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""phase43 — scheduled inspection day-of-week / day-of-month recurrence
|
||||
|
||||
Adds the recurrence-detail columns to `scheduled_inspections` so a weekly
|
||||
schedule can name the weekdays it runs on (Mon/Wed/Fri) and a monthly schedule
|
||||
can name either a day of the month ("the 15th") or an nth weekday ("the 2nd
|
||||
Tuesday"):
|
||||
|
||||
weekdays VARCHAR(20) -- CSV of Python weekday ints, Mon=0, e.g. '0,2,4'
|
||||
month_mode VARCHAR(20) -- 'day_of_month' | 'nth_weekday'
|
||||
day_of_month SMALLINT -- 1–31, clamped to the month's last day
|
||||
nth_week SMALLINT -- 1–5, or -1 for "last"
|
||||
nth_weekday SMALLINT -- 0–6, Mon=0
|
||||
|
||||
All nullable with no backfill: existing phase36 rows keep NULLs and fall back to
|
||||
the legacy "+7 days" / "same day next month" behaviour in
|
||||
ScheduledInspection._add_interval(), so no schedule changes cadence on deploy.
|
||||
|
||||
`month_mode` is VARCHAR rather than ENUM so adding a recurrence style later
|
||||
needs no 3-step MySQL ENUM migration (CLAUDE.md rule 3).
|
||||
|
||||
Uses INFORMATION_SCHEMA column-existence checks — safe to re-run.
|
||||
"""
|
||||
|
||||
revision = 'phase43_sched_recurrence'
|
||||
down_revision = 'phase42_internal_contact'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
_COLUMNS = (
|
||||
('weekdays', 'VARCHAR(20) NULL'),
|
||||
('month_mode', 'VARCHAR(20) NULL'),
|
||||
('day_of_month', 'SMALLINT NULL'),
|
||||
('nth_week', 'SMALLINT NULL'),
|
||||
('nth_weekday', 'SMALLINT NULL'),
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
for name, ddl in _COLUMNS:
|
||||
if not _column_exists(bind, 'scheduled_inspections', name):
|
||||
op.execute(sa.text(
|
||||
f"ALTER TABLE scheduled_inspections ADD COLUMN {name} {ddl}"
|
||||
))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
for name, _ in reversed(_COLUMNS):
|
||||
if _column_exists(bind, 'scheduled_inspections', name):
|
||||
op.execute(sa.text(
|
||||
f"ALTER TABLE scheduled_inspections DROP COLUMN {name}"
|
||||
))
|
||||
@@ -0,0 +1,56 @@
|
||||
"""phase44 — scheduled inspection end date
|
||||
|
||||
Adds `end_date` to `scheduled_inspections`:
|
||||
|
||||
end_date DATE NULL -- last date this schedule may produce an occurrence
|
||||
|
||||
Separates the two ideas that `next_due_date` was carrying at once. `next_due_date`
|
||||
is *mutable state* — ScheduledInspection.fulfill() rewrites it after every
|
||||
completed inspection — whereas `end_date` is a *fixed boundary* set by the
|
||||
manager and never touched by the app. NULL means "repeat indefinitely", which is
|
||||
the behaviour every existing row has today, so no backfill and no schedule
|
||||
changes cadence on deploy.
|
||||
|
||||
Only meaningful for recurring schedules; the create/edit routes force it to NULL
|
||||
when frequency == 'once' (a one-time schedule already ends by deactivating on
|
||||
completion).
|
||||
|
||||
Uses an INFORMATION_SCHEMA column-existence check — safe to re-run.
|
||||
Additive only: no existing column is renamed, retyped or dropped. In particular
|
||||
`next_due_date` keeps its name and its index — it is the API payload key the
|
||||
iPad decodes and is used by the reminder cron.
|
||||
"""
|
||||
|
||||
revision = 'phase44_sched_end_date'
|
||||
down_revision = 'phase43_sched_recurrence'
|
||||
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, 'scheduled_inspections', 'end_date'):
|
||||
op.execute(sa.text(
|
||||
"ALTER TABLE scheduled_inspections "
|
||||
"ADD COLUMN end_date DATE NULL AFTER next_due_date"
|
||||
))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
if _column_exists(bind, 'scheduled_inspections', 'end_date'):
|
||||
op.execute(sa.text(
|
||||
"ALTER TABLE scheduled_inspections DROP COLUMN end_date"
|
||||
))
|
||||
@@ -0,0 +1,82 @@
|
||||
"""phase45 — scheduled follow-up: link a schedule back to its parent inspection
|
||||
|
||||
Adds `parent_inspection_id` to `scheduled_inspections`:
|
||||
|
||||
parent_inspection_id INT NULL -- inspection this schedule is a follow-up of
|
||||
|
||||
Lets a follow-up be *planned for a later date* rather than started immediately.
|
||||
The iPad's inspection history detail gains a "Schedule Follow-up" action next to
|
||||
"Re-inspect Now": it creates a one-time schedule carrying this column, and when
|
||||
the inspector eventually starts it the resulting inspection inherits
|
||||
`parent_inspection_id` — so it lands as a true linked re-inspection (pre-filled
|
||||
from the parent, clearing the parent's `follow_up_required` on submit) exactly
|
||||
as if they had tapped "Re-inspect Now" on the day.
|
||||
|
||||
Without the column the scheduled run would be an ordinary inspection: no link,
|
||||
no prefill, and the parent's follow-up flag would stay set forever.
|
||||
|
||||
NULL means "not a follow-up", which is what every existing row is, so there is
|
||||
no backfill and no schedule changes behaviour on deploy.
|
||||
|
||||
ON DELETE SET NULL: deleting the parent inspection must not cascade away a
|
||||
schedule the inspector still has to perform — it just stops being a follow-up.
|
||||
|
||||
Uses an INFORMATION_SCHEMA existence check — safe to re-run. Additive only.
|
||||
"""
|
||||
|
||||
revision = 'phase45_sched_parent_insp'
|
||||
down_revision = 'phase44_sched_end_date'
|
||||
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 _constraint_exists(conn, table, name):
|
||||
result = conn.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() "
|
||||
"AND TABLE_NAME = :t AND CONSTRAINT_NAME = :n"
|
||||
), {"t": table, "n": name})
|
||||
return result.scalar() > 0
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
if not _column_exists(bind, 'scheduled_inspections', 'parent_inspection_id'):
|
||||
op.execute(sa.text(
|
||||
"ALTER TABLE scheduled_inspections "
|
||||
"ADD COLUMN parent_inspection_id INT NULL AFTER notes"
|
||||
))
|
||||
if not _constraint_exists(bind, 'scheduled_inspections',
|
||||
'fk_sched_insp_parent_inspection'):
|
||||
op.execute(sa.text(
|
||||
"ALTER TABLE scheduled_inspections "
|
||||
"ADD CONSTRAINT fk_sched_insp_parent_inspection "
|
||||
"FOREIGN KEY (parent_inspection_id) REFERENCES inspections(id) "
|
||||
"ON DELETE SET NULL"
|
||||
))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
if _constraint_exists(bind, 'scheduled_inspections',
|
||||
'fk_sched_insp_parent_inspection'):
|
||||
op.execute(sa.text(
|
||||
"ALTER TABLE scheduled_inspections "
|
||||
"DROP FOREIGN KEY fk_sched_insp_parent_inspection"
|
||||
))
|
||||
if _column_exists(bind, 'scheduled_inspections', 'parent_inspection_id'):
|
||||
op.execute(sa.text(
|
||||
"ALTER TABLE scheduled_inspections DROP COLUMN parent_inspection_id"
|
||||
))
|
||||
@@ -0,0 +1,74 @@
|
||||
"""phase46 — follow-up request attribution (customer-raised follow-ups)
|
||||
|
||||
Adds to `inspections`:
|
||||
|
||||
follow_up_requested_by INT NULL FK → users(id) ON DELETE SET NULL
|
||||
follow_up_requested_at DATETIME NULL
|
||||
|
||||
Customers can now request a follow-up re-inspection of a completed inspection at
|
||||
their own facilities (previously admin/director only), so `follow_up_required`
|
||||
alone is no longer enough — staff need to see WHO is waiting on the
|
||||
re-inspection, and a client request must be visibly distinct from an internal
|
||||
one. `flag_followup()` sets both columns; `clear_followup()` nulls them.
|
||||
|
||||
No backfill: legacy rows keep NULL, which the UI renders as an unattributed
|
||||
follow-up exactly as it did before. FK is SET NULL so deleting a user never
|
||||
deletes inspection history.
|
||||
|
||||
Uses INFORMATION_SCHEMA checks — safe to re-run.
|
||||
"""
|
||||
|
||||
revision = 'phase46_followup_req_by'
|
||||
down_revision = 'phase45_sched_parent_insp'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
_FK_NAME = 'fk_inspections_follow_up_requested_by'
|
||||
|
||||
|
||||
def _column_exists(conn, table, column):
|
||||
return 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}).scalar() > 0
|
||||
|
||||
|
||||
def _fk_exists(conn, table, name):
|
||||
return conn.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t "
|
||||
"AND CONSTRAINT_NAME = :n AND CONSTRAINT_TYPE = 'FOREIGN KEY'"
|
||||
), {"t": table, "n": name}).scalar() > 0
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
|
||||
if not _column_exists(bind, 'inspections', 'follow_up_requested_by'):
|
||||
op.execute(sa.text(
|
||||
"ALTER TABLE inspections ADD COLUMN follow_up_requested_by INT NULL"
|
||||
))
|
||||
if not _column_exists(bind, 'inspections', 'follow_up_requested_at'):
|
||||
op.execute(sa.text(
|
||||
"ALTER TABLE inspections ADD COLUMN follow_up_requested_at DATETIME NULL"
|
||||
))
|
||||
if not _fk_exists(bind, 'inspections', _FK_NAME):
|
||||
op.execute(sa.text(
|
||||
f"ALTER TABLE inspections ADD CONSTRAINT {_FK_NAME} "
|
||||
"FOREIGN KEY (follow_up_requested_by) REFERENCES users(id) "
|
||||
"ON DELETE SET NULL"
|
||||
))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
if _fk_exists(bind, 'inspections', _FK_NAME):
|
||||
op.execute(sa.text(f"ALTER TABLE inspections DROP FOREIGN KEY {_FK_NAME}"))
|
||||
for col in ('follow_up_requested_at', 'follow_up_requested_by'):
|
||||
if _column_exists(bind, 'inspections', col):
|
||||
op.execute(sa.text(f"ALTER TABLE inspections DROP COLUMN {col}"))
|
||||
Reference in New Issue
Block a user