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}" + ))