This commit is contained in:
2026-07-31 15:04:10 -04:00
23 changed files with 1461 additions and 70 deletions
+92 -3
View File
@@ -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: 15, or -1 = last
nth_weekday SMALLINT nullable -- Phase 43: 06, 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. |
---
+68
View File
@@ -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
View File
@@ -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'
+135
View File
@@ -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)
+12
View File
@@ -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')
+6
View File
@@ -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',
}
+12
View File
@@ -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,
+222 -11
View File
@@ -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: 14 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 2831 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
View File
@@ -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
+5
View File
@@ -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,
+66 -11
View File
@@ -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_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,13 +1278,33 @@ 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}')
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))
@@ -1266,6 +1319,8 @@ def clear_followup(inspection_id):
abort(404)
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}',
+135 -10
View File
@@ -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
+9 -1
View File
@@ -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 %}
+15
View File
@@ -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 %}
+39 -3
View File
@@ -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>
+120 -1
View File
@@ -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 %}
+23 -1
View File
@@ -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
View File
@@ -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 (131).')
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
View File
@@ -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 -- 131, clamped to the month's last day
nth_week SMALLINT -- 15, or -1 for "last"
nth_weekday SMALLINT -- 06, 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}"))