diff --git a/CLAUDE.md b/CLAUDE.md index e019023..4a359ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -417,16 +417,44 @@ scheduled_inspections: id, facility_id (FK→facilities CASCADE), template_id (FK→inspection_templates CASCADE), inspector_id (FK→users SET NULL), frequency ENUM('once','daily','weekly','monthly'), next_due_date DATE, active BOOL, notes TEXT, created_by, created_at, - last_completed_at DATETIME, advance_notified BOOL, due_notified BOOL, overdue_notified BOOL + last_completed_at DATETIME, advance_notified BOOL, due_notified BOOL, overdue_notified BOOL, + weekdays VARCHAR(20) nullable, -- Phase 43: CSV weekday ints, Mon=0, e.g. '0,2,4' + month_mode VARCHAR(20) nullable, -- Phase 43: 'day_of_month' | 'nth_weekday' + day_of_month SMALLINT nullable, -- Phase 43 + nth_week SMALLINT nullable, -- Phase 43: 1–5, or -1 = last + nth_weekday SMALLINT nullable -- Phase 43: 0–6, Mon=0 inspections.scheduled_inspection_id FK→scheduled_inspections SET NULL ← Phase 36 ``` +**Recurrence detail (Phase 43).** `frequency` says *how often*; these columns say *which day*: + +| frequency | columns used | example | +|---|---|---| +| `once` / `daily` | none (all NULL) | — | +| `weekly` | `weekdays` | `'0,2,4'` → Mon/Wed/Fri | +| `monthly` + `month_mode='day_of_month'` | `day_of_month` | the 15th (clamped to the month's last day) | +| `monthly` + `month_mode='nth_weekday'` | `nth_week`, `nth_weekday` | 2nd Tuesday (`nth_week=-1` → last) | + +All are nullable and **legacy phase36 rows keep NULLs**, falling back to `_add_interval()`'s "+7 days" / "same day next month" — no schedule changes cadence on deploy. `_apply_recurrence()` (in the blueprint) **clears the columns that don't apply** to the chosen frequency, so a weekly→monthly switch can't leave stale weekdays behind. + +- `ScheduledInspection.next_occurrence_after(d)` — first occurrence strictly after `d`, honouring the rule. Used by `fulfill()`; a Mon/Wed/Fri schedule rolls Mon→Wed→Fri→Mon, so **one row yields three inspections a week**. +- `align_due_date(d)` — snaps the manager's picked start date forward to the first matching day (pick a Tuesday for Mon/Wed/Fri → get that Wednesday). Applied on both create and edit. +- `recurrence_label` — display string (`"Weekly · Mon, Wed, Fri"`), shown on the schedule list and the dashboard panel in place of the bare `frequency_label`. +- `weekday_list` / `set_weekdays()` — parse/format the CSV column. `ScheduledInspectionForm(obj=sched)` copies the raw CSV into the multi-select, so the edit route re-assigns `form.weekdays.data = sched.weekday_list` on GET. +- A requested 5th weekday that doesn't exist in a month falls back to the 4th; `day_of_month=31` clamps to Feb 28/29. Every month yields a valid date. + +**Duplicate-Start guard.** `start()` returns the existing `in_progress` inspection for the schedule instead of creating a second one, and both the schedule list and the dashboard panel show **Continue** (via `_open_inspection_ids()`) rather than **Start** while one is underway. + **A plan, not an inspection.** Names a facility + template + assigned inspector + `next_due_date`. Lifecycle: - The assigned inspector (or a manager) clicks **Start** → `scheduled_inspections.start` creates a normal `in_progress` Inspection with `scheduled_inspection_id` set, then redirects to the execute flow. - On **completion** (execute route, status → `completed`), `ScheduledInspection.fulfill()` runs in the same atomic commit: `once` → `active=False`; recurring → `next_due_date` rolls forward past today via `_add_interval()` and the three `*_notified` flags reset. +- **End date (phase44).** `end_date` is the manager's boundary; NULL = forever, and it is forced NULL for `once`. Inclusive — an occurrence landing exactly on it still runs. Two enforcement points, both needed: `fulfill()` deactivates when the rolled-forward `next_due_date` passes the boundary (the schedule that ends by being *completed*), and `run_reminders()` calls `expire_if_past_end_date()` on every active schedule before doing any reminder work (the schedule that reaches its boundary *without ever being done* — otherwise it re-alerts as overdue forever). `next_due_date` is left unclamped on expiry so the row shows which occurrence it stopped before. +- **Form validation.** `ScheduledInspectionForm.validate()` rejects an end date on a one-time schedule and one earlier than the due date. That is not sufficient alone: `align_due_date()` can push the picked date forward onto the rule (a Tuesday pick on a Mon/Wed/Fri schedule becomes Wednesday), so `_reject_if_past_end_date()` re-checks after `_apply_recurrence()` in both create and edit. Edit rolls back first — `sched` is persistent and already mutated at that point. +- **Three status states** in the list: Active, **Ended** (`is_expired` — ran its course), Inactive (a manager switched it off). - **Assignment notification** (immediate): on **create**, the assigned inspector gets an in-app + email "assigned to you" notification; on **edit**, only when the inspector actually changes (a "reassigned to you" notification to the new assignee). Via `_notify_assignee()` in the blueprint using `event_type=EVENT_SCHEDULED_INSPECTION`. - **Reminders** are dispatched by the cron endpoint (see §11): advance (1 day before) + due-date to the inspector, overdue to admin/director — each fires at most once per occurrence via the `*_notified` flags. Uses `notify()` with `event_type=EVENT_SCHEDULED_INSPECTION`. - Dashboard shows an **upcoming (next 7 days) / overdue** panel for non-customers (inspectors see only their own). +- **Instructions (July 2026).** `ScheduledInspectionForm.notes` is labelled **"Instructions"** and `scheduled_inspections/form.html` explains that the text reaches the inspector. The *field name*, `ScheduledInspection.notes`, the `scheduled_inspections.notes` column and the API key `notes` are all unchanged — the rename is a label only (rule 84). The text is surfaced to the inspector in two places: `inspections/execute.html` renders an indigo panel between the header and the form grid, guarded on `inspection.scheduled_inspection and .notes` (NULL for ad-hoc work and for schedules deleted after the start); the iPad shows it on the scheduled row, on the start screen and above the form. - **"Scheduled" badge:** an inspection started from a schedule carries `scheduled_inspection_id`. `Inspection.scheduled_inspection` (relationship, foreign_keys on that column) resolves the source schedule (None if ad-hoc or the schedule was later deleted). The inspection **detail** view header shows a `bi-calendar-check` "Scheduled · " 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 //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//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/` 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//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 ` diff --git a/app/templates/scheduled_inspections/form.html b/app/templates/scheduled_inspections/form.html index 410e35d..2497826 100644 --- a/app/templates/scheduled_inspections/form.html +++ b/app/templates/scheduled_inspections/form.html @@ -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 %}
{{ e }}
{% endfor %} +
+ Snapped forward to the first matching day. + Advances automatically after each completed inspection. +
+ + + + {# ── 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. #} + + + {# ── Weekly: which days of the week ──────────────────────────── #} + + + {# ── Monthly: day-of-month OR nth weekday ────────────────────── #} +
{{ 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.") }} +
+ + Shown to the assigned inspector when they open this inspection, on the web and on the iPad. +
@@ -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(); +}()); {% endblock %} diff --git a/app/templates/scheduled_inspections/list.html b/app/templates/scheduled_inspections/list.html index 635def4..f11b3ea 100644 --- a/app/templates/scheduled_inspections/list.html +++ b/app/templates/scheduled_inspections/list.html @@ -31,6 +31,7 @@ Inspector Frequency Next Due + Ends Status @@ -43,7 +44,7 @@ {{ s.facility.name if s.facility else '—' }} {{ s.template.name if s.template else '—' }} {{ s.inspector.display_name if s.inspector else '— Unassigned —' }} - {{ s.frequency_label }} + {{ s.recurrence_label }} {{ s.next_due_date.strftime('%b %d, %Y') }} {% if overdue %} @@ -52,9 +53,22 @@ Due soon {% endif %} + + {% if s.frequency == 'once' %} + + {% elif s.end_date %} + {{ s.end_date.strftime('%b %d, %Y') }} + {% else %} + No end + {% endif %} + + {# Three states, not two: "Ended" distinguishes a schedule that ran + its course from one a manager switched off. #} {% if s.active %} Active + {% elif s.is_expired %} + Ended {% else %} Inactive {% endif %} @@ -62,11 +76,19 @@ {# 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 %} + + Continue + + {% else %} Start {% endif %} + {% endif %} {% if current_user.role in ['admin','director','project_manager','auditor'] %} diff --git a/app/utils/forms.py b/app/utils/forms.py index f2da4e1..1d24de6 100644 --- a/app/utils/forms.py +++ b/app/utils/forms.py @@ -2,7 +2,7 @@ from flask_wtf import FlaskForm from flask_wtf.file import FileField, FileAllowed, MultipleFileField from wtforms import (StringField, PasswordField, SelectField, TextAreaField, DecimalField, BooleanField, IntegerField, HiddenField, - RadioField, DateField) + RadioField, DateField, SelectMultipleField) from wtforms.validators import (DataRequired, Email, Length, EqualTo, Optional, NumberRange, ValidationError) from app.models.user import User @@ -338,10 +338,75 @@ class ScheduledInspectionForm(FlaskForm): ('once', 'One-time'), ('daily', 'Daily'), ('weekly', 'Weekly'), ('monthly', 'Monthly'), ], validators=[DataRequired()]) - next_due_date = DateField('Due Date', validators=[DataRequired()]) - notes = TextAreaField('Notes', validators=[Optional(), Length(max=1000)]) + next_due_date = DateField('Start / Due Date', validators=[DataRequired()]) + # Label is overridden per context in routes/scheduled_inspections.py: + # "Start Date" when creating, "Next Due Date" when editing. The default + # above is only a fallback. + end_date = DateField('End Date', validators=[Optional()]) + # UI label only. The field name, the ScheduledInspection.notes attribute and + # the scheduled_inspections.notes column all stay `notes` — renaming any of + # them would break the API payload key the iPad decodes. + notes = TextAreaField('Instructions', validators=[Optional(), Length(max=1000)]) active = BooleanField('Active', default=True) + # ── Recurrence detail (phase43) ────────────────────────────────────────── + # Only the block matching `frequency` is required; the rest is ignored and + # cleared on save. Shown/hidden client-side, enforced in validate() below. + weekdays = SelectMultipleField( + 'Days of the Week', coerce=int, validators=[Optional()], + choices=[(i, n) for i, n in enumerate( + ['Monday', 'Tuesday', 'Wednesday', 'Thursday', + 'Friday', 'Saturday', 'Sunday'])], + ) + month_mode = SelectField('Monthly Rule', validators=[Optional()], choices=[ + ('day_of_month', 'On a day of the month'), + ('nth_weekday', 'On a weekday of the month'), + ], default='day_of_month') + day_of_month = IntegerField( + 'Day of Month', validators=[Optional(), NumberRange(min=1, max=31)]) + nth_week = SelectField('Week', coerce=int, validators=[Optional()], choices=[ + (1, '1st'), (2, '2nd'), (3, '3rd'), (4, '4th'), (5, '5th'), (-1, 'Last'), + ], default=1) + nth_weekday = SelectField( + 'Weekday', coerce=int, validators=[Optional()], + choices=[(i, n) for i, n in enumerate( + ['Monday', 'Tuesday', 'Wednesday', 'Thursday', + 'Friday', 'Saturday', 'Sunday'])], + default=0, + ) + + def validate(self, extra_validators=None): + """Conditionally require the recurrence block for the chosen frequency.""" + if not super().validate(extra_validators): + return False + ok = True + if self.frequency.data == 'weekly' and not self.weekdays.data: + self.weekdays.errors.append('Pick at least one day of the week.') + ok = False + elif self.frequency.data == 'monthly': + if self.month_mode.data == 'nth_weekday': + if not self.nth_week.data or self.nth_weekday.data is None: + self.nth_week.errors.append('Choose which weekday of the month.') + ok = False + elif not self.day_of_month.data: + self.day_of_month.errors.append('Enter a day of the month (1–31).') + ok = False + + # End date (phase44). Only meaningful for recurring schedules — a + # one-time schedule ends by deactivating when it is completed. Rejecting + # an end date before the due date here is what makes the "already past + # its boundary on save" case unreachable in the routes. + if self.end_date.data: + if self.frequency.data == 'once': + self.end_date.errors.append( + 'A one-time schedule has no end date — it closes when completed.') + ok = False + elif self.next_due_date.data and self.end_date.data < self.next_due_date.data: + self.end_date.errors.append( + 'End date must be on or after the due date.') + ok = False + return ok + # ── Support Knowledge Base (phase38) ───────────────────────────────────────── diff --git a/app/utils/photo_stamp.py b/app/utils/photo_stamp.py index 7abc8e9..54185a6 100644 --- a/app/utils/photo_stamp.py +++ b/app/utils/photo_stamp.py @@ -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 diff --git a/migrations/versions/phase43_scheduled_recurrence.py b/migrations/versions/phase43_scheduled_recurrence.py new file mode 100644 index 0000000..58896ac --- /dev/null +++ b/migrations/versions/phase43_scheduled_recurrence.py @@ -0,0 +1,66 @@ +"""phase43 — scheduled inspection day-of-week / day-of-month recurrence + +Adds the recurrence-detail columns to `scheduled_inspections` so a weekly +schedule can name the weekdays it runs on (Mon/Wed/Fri) and a monthly schedule +can name either a day of the month ("the 15th") or an nth weekday ("the 2nd +Tuesday"): + + weekdays VARCHAR(20) -- CSV of Python weekday ints, Mon=0, e.g. '0,2,4' + month_mode VARCHAR(20) -- 'day_of_month' | 'nth_weekday' + day_of_month SMALLINT -- 1–31, clamped to the month's last day + nth_week SMALLINT -- 1–5, or -1 for "last" + nth_weekday SMALLINT -- 0–6, Mon=0 + +All nullable with no backfill: existing phase36 rows keep NULLs and fall back to +the legacy "+7 days" / "same day next month" behaviour in +ScheduledInspection._add_interval(), so no schedule changes cadence on deploy. + +`month_mode` is VARCHAR rather than ENUM so adding a recurrence style later +needs no 3-step MySQL ENUM migration (CLAUDE.md rule 3). + +Uses INFORMATION_SCHEMA column-existence checks — safe to re-run. +""" + +revision = 'phase43_sched_recurrence' +down_revision = 'phase42_internal_contact' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +_COLUMNS = ( + ('weekdays', 'VARCHAR(20) NULL'), + ('month_mode', 'VARCHAR(20) NULL'), + ('day_of_month', 'SMALLINT NULL'), + ('nth_week', 'SMALLINT NULL'), + ('nth_weekday', 'SMALLINT NULL'), +) + + +def _column_exists(conn, table, column): + result = conn.execute(sa.text( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() " + "AND TABLE_NAME = :t AND COLUMN_NAME = :c" + ), {"t": table, "c": column}) + return result.scalar() > 0 + + +def upgrade(): + bind = op.get_bind() + for name, ddl in _COLUMNS: + if not _column_exists(bind, 'scheduled_inspections', name): + op.execute(sa.text( + f"ALTER TABLE scheduled_inspections ADD COLUMN {name} {ddl}" + )) + + +def downgrade(): + bind = op.get_bind() + for name, _ in reversed(_COLUMNS): + if _column_exists(bind, 'scheduled_inspections', name): + op.execute(sa.text( + f"ALTER TABLE scheduled_inspections DROP COLUMN {name}" + )) diff --git a/migrations/versions/phase44_scheduled_end_date.py b/migrations/versions/phase44_scheduled_end_date.py new file mode 100644 index 0000000..b4f09b5 --- /dev/null +++ b/migrations/versions/phase44_scheduled_end_date.py @@ -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" + )) diff --git a/migrations/versions/phase45_scheduled_parent_inspection.py b/migrations/versions/phase45_scheduled_parent_inspection.py new file mode 100644 index 0000000..5b7e71b --- /dev/null +++ b/migrations/versions/phase45_scheduled_parent_inspection.py @@ -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" + )) diff --git a/migrations/versions/phase46_followup_requested_by.py b/migrations/versions/phase46_followup_requested_by.py new file mode 100644 index 0000000..6957afb --- /dev/null +++ b/migrations/versions/phase46_followup_requested_by.py @@ -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}"))