From 0c63c45b21205ce849507678f4f10a5caf8beffc Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Sun, 26 Jul 2026 11:09:01 -0400 Subject: [PATCH 1/8] Jul 26 - Update timestamp smaller and more transparent --- app/utils/photo_stamp.py | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) 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 From 16858108b9814a653e697735e27e0141cc168db8 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Sun, 26 Jul 2026 13:47:17 -0400 Subject: [PATCH 2/8] Jul 26 - Update scheduled inspection settings (weekly/monthly) --- CLAUDE.md | 40 ++++- app/api/scheduled.py | 8 + app/models/scheduled_inspection.py | 155 ++++++++++++++++-- app/routes/dashboard.py | 5 + app/routes/scheduled_inspections.py | 76 ++++++++- app/templates/dashboard.html | 10 +- app/templates/scheduled_inspections/form.html | 93 +++++++++++ app/templates/scheduled_inspections/list.html | 10 +- app/utils/forms.py | 48 +++++- .../versions/phase43_scheduled_recurrence.py | 66 ++++++++ 10 files changed, 488 insertions(+), 23 deletions(-) create mode 100644 migrations/versions/phase43_scheduled_recurrence.py diff --git a/CLAUDE.md b/CLAUDE.md index e019023..4498fb6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -417,10 +417,34 @@ 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. @@ -827,7 +851,18 @@ 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 ← HEAD +``` + +### 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 +1388,7 @@ 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 ` + + + {% endfor %} + + {% for e in form.weekdays.errors %}
{{ e }}
{% endfor %} +
+ 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. +
+ + + {# ── Monthly: day-of-month OR nth weekday ────────────────────── #} + @@ -129,5 +187,40 @@ 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'); + 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'; + 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..601a166 100644 --- a/app/templates/scheduled_inspections/list.html +++ b/app/templates/scheduled_inspections/list.html @@ -43,7 +43,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 %} @@ -62,11 +62,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..37c412b 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,54 @@ class ScheduledInspectionForm(FlaskForm): ('once', 'One-time'), ('daily', 'Daily'), ('weekly', 'Weekly'), ('monthly', 'Monthly'), ], validators=[DataRequired()]) - next_due_date = DateField('Due Date', validators=[DataRequired()]) + next_due_date = DateField('Start / Due Date', validators=[DataRequired()]) notes = TextAreaField('Notes', 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 + return ok + # ── Support Knowledge Base (phase38) ───────────────────────────────────────── 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}" + )) From 3ab84ac016dc63d5112bbbf6d83146f4db652fc1 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Sun, 26 Jul 2026 14:51:50 -0400 Subject: [PATCH 3/8] Jul 26 - Update scheduled inspection settings for iPad app --- CLAUDE.md | 10 ++++- app/api/inspections.py | 88 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4498fb6..842d430 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -645,11 +645,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. @@ -1388,6 +1395,7 @@ 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 ``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. | | 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. | | 81 | **Photo timestamp/geo overlay is burned at UPLOAD, never on `PATCH /issues//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. | --- diff --git a/app/templates/inspections/execute.html b/app/templates/inspections/execute.html index 42ab574..94c957a 100644 --- a/app/templates/inspections/execute.html +++ b/app/templates/inspections/execute.html @@ -263,6 +263,21 @@ + {# ── 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 %} +
+
+ Instructions for this inspection +
+
{{ inspection.scheduled_inspection.notes }}
+
+ {% endif %} + {# ── Form body ── #}
{% if form_fields %} diff --git a/app/templates/scheduled_inspections/form.html b/app/templates/scheduled_inspections/form.html index 43bd32c..0379a8c 100644 --- a/app/templates/scheduled_inspections/form.html +++ b/app/templates/scheduled_inspections/form.html @@ -112,7 +112,11 @@
{{ 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. +
diff --git a/app/utils/forms.py b/app/utils/forms.py index 37c412b..3f30e1c 100644 --- a/app/utils/forms.py +++ b/app/utils/forms.py @@ -339,7 +339,10 @@ class ScheduledInspectionForm(FlaskForm): ('weekly', 'Weekly'), ('monthly', 'Monthly'), ], validators=[DataRequired()]) next_due_date = DateField('Start / Due Date', validators=[DataRequired()]) - notes = TextAreaField('Notes', validators=[Optional(), Length(max=1000)]) + # 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) ────────────────────────────────────────── From 455534ccda18c2e917b8b9080f03865d746c44d1 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Mon, 27 Jul 2026 15:26:06 -0400 Subject: [PATCH 5/8] Jul 27 - Update code for scheduled tasks 2 --- CLAUDE.md | 8 +++ DEPLOYMENT_MAP.txt | 68 +++++++++++++++++++ app/api/scheduled.py | 3 + app/models/scheduled_inspection.py | 57 +++++++++++++++- app/routes/scheduled_inspections.py | 63 ++++++++++++++++- app/templates/scheduled_inspections/form.html | 24 ++++++- app/templates/scheduled_inspections/list.html | 14 ++++ app/utils/forms.py | 18 +++++ .../versions/phase44_scheduled_end_date.py | 56 +++++++++++++++ 9 files changed, 306 insertions(+), 5 deletions(-) create mode 100644 DEPLOYMENT_MAP.txt create mode 100644 migrations/versions/phase44_scheduled_end_date.py diff --git a/CLAUDE.md b/CLAUDE.md index c45cf7e..445271b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -448,6 +448,9 @@ All are nullable and **legacy phase36 rows keep NULLs**, falling back to `_add_i **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). @@ -863,6 +866,10 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif → phase43_sched_recurrence ← HEAD ``` +#### 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. @@ -1399,6 +1406,7 @@ timeout = 30 | 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//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. | --- diff --git a/DEPLOYMENT_MAP.txt b/DEPLOYMENT_MAP.txt new file mode 100644 index 0000000..9bf65f3 --- /dev/null +++ b/DEPLOYMENT_MAP.txt @@ -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. diff --git a/app/api/scheduled.py b/app/api/scheduled.py index e6cd533..566081f 100644 --- a/app/api/scheduled.py +++ b/app/api/scheduled.py @@ -51,6 +51,9 @@ def _scheduled_payload(s): '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, } diff --git a/app/models/scheduled_inspection.py b/app/models/scheduled_inspection.py index 4f74e32..62f64ea 100644 --- a/app/models/scheduled_inspection.py +++ b/app/models/scheduled_inspection.py @@ -17,6 +17,12 @@ 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. """ import calendar @@ -85,6 +91,11 @@ 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) @@ -227,10 +238,48 @@ class ScheduledInspection(db.Model): 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 @@ -243,6 +292,12 @@ class ScheduledInspection(db.Model): 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 diff --git a/app/routes/scheduled_inspections.py b/app/routes/scheduled_inspections.py index f0d9d7c..1b7e142 100644 --- a/app/routes/scheduled_inspections.py +++ b/app/routes/scheduled_inspections.py @@ -106,10 +106,32 @@ def _apply_recurrence(sched, form): 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.""" @@ -167,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() @@ -181,11 +207,18 @@ def create(): 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.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) @@ -213,6 +246,7 @@ 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. @@ -227,10 +261,20 @@ def edit(schedule_id): 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.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. @@ -333,10 +377,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 @@ -395,6 +452,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 diff --git a/app/templates/scheduled_inspections/form.html b/app/templates/scheduled_inspections/form.html index 0379a8c..2497826 100644 --- a/app/templates/scheduled_inspections/form.html +++ b/app/templates/scheduled_inspections/form.html @@ -49,7 +49,26 @@ {{ 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.
+
+ 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. #} + @@ -201,6 +220,7 @@ 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'); @@ -217,6 +237,8 @@ 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(); } diff --git a/app/templates/scheduled_inspections/list.html b/app/templates/scheduled_inspections/list.html index 601a166..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 @@ -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 %} diff --git a/app/utils/forms.py b/app/utils/forms.py index 3f30e1c..1d24de6 100644 --- a/app/utils/forms.py +++ b/app/utils/forms.py @@ -339,6 +339,10 @@ class ScheduledInspectionForm(FlaskForm): ('weekly', 'Weekly'), ('monthly', 'Monthly'), ], validators=[DataRequired()]) 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. @@ -387,6 +391,20 @@ class ScheduledInspectionForm(FlaskForm): 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 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" + )) From 12bcda299430cf10dee61acdee8e2514b6003ff1 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Thu, 30 Jul 2026 15:07:23 -0400 Subject: [PATCH 6/8] Jul 30 - Update backend for iPad follow-up request function --- app/api/inspections.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/app/api/inspections.py b/app/api/inspections.py index 213682c..6da968f 100644 --- a/app/api/inspections.py +++ b/app/api/inspections.py @@ -13,6 +13,7 @@ PATCH /api/v1/inspections/ 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 @@ -235,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 ------------ @@ -271,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: From 5ed433aabeab6c3f94b2a8ed3a83285cbbcb29f3 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Thu, 30 Jul 2026 16:08:58 -0400 Subject: [PATCH 7/8] Jul 30 - Update backend for iPad - inspector can re-inspect and create follow-up --- app/api/inspections.py | 13 ++ app/api/scheduled.py | 124 ++++++++++++++++++ app/models/scheduled_inspection.py | 21 +++ app/routes/scheduled_inspections.py | 6 + .../phase45_scheduled_parent_inspection.py | 82 ++++++++++++ 5 files changed, 246 insertions(+) create mode 100644 migrations/versions/phase45_scheduled_parent_inspection.py diff --git a/app/api/inspections.py b/app/api/inspections.py index 6da968f..46d0f0f 100644 --- a/app/api/inspections.py +++ b/app/api/inspections.py @@ -421,6 +421,19 @@ def create_inspection(): 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': diff --git a/app/api/scheduled.py b/app/api/scheduled.py index 566081f..97adf01 100644 --- a/app/api/scheduled.py +++ b/app/api/scheduled.py @@ -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__) @@ -56,6 +60,11 @@ def _scheduled_payload(s): '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, } @@ -113,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) diff --git a/app/models/scheduled_inspection.py b/app/models/scheduled_inspection.py index 62f64ea..b477b7b 100644 --- a/app/models/scheduled_inspection.py +++ b/app/models/scheduled_inspection.py @@ -99,6 +99,20 @@ class ScheduledInspection(db.Model): 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. @@ -126,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', diff --git a/app/routes/scheduled_inspections.py b/app/routes/scheduled_inspections.py index 1b7e142..b225039 100644 --- a/app/routes/scheduled_inspections.py +++ b/app/routes/scheduled_inspections.py @@ -356,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() 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" + )) From 0808f8eaff0c36d072453e9cd608b9638109f457 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Thu, 30 Jul 2026 20:44:38 -0400 Subject: [PATCH 8/8] Jul 30 - Allow customer to flag follow-up a inspection --- CLAUDE.md | 37 +++++++- app/models/inspection.py | 12 +++ app/models/notification.py | 6 ++ app/models/notification_matrix.py | 12 +++ app/models/user.py | 7 +- app/routes/inspections.py | 87 +++++++++++++++---- app/templates/inspections/view.html | 42 ++++++++- .../versions/phase46_followup_requested_by.py | 74 ++++++++++++++++ 8 files changed, 256 insertions(+), 21 deletions(-) create mode 100644 migrations/versions/phase46_followup_requested_by.py diff --git a/CLAUDE.md b/CLAUDE.md index 445271b..4a359ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -477,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 | ✅ | ✅ | ❌ | ❌ | ❌ | @@ -751,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: @@ -863,7 +880,24 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif → phase40_auditor_role → phase41_internal_handler → phase42_internal_contact - → phase43_sched_recurrence ← HEAD + → 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 @@ -1403,6 +1437,7 @@ 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/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}"))