Jul 26 - Update scheduled inspection settings (weekly/monthly)

This commit is contained in:
2026-07-26 13:47:17 -04:00
parent 0c63c45b21
commit 16858108b9
10 changed files with 488 additions and 23 deletions
+38 -2
View File
@@ -417,10 +417,34 @@ scheduled_inspections:
id, facility_id (FK→facilities CASCADE), template_id (FK→inspection_templates CASCADE), id, facility_id (FK→facilities CASCADE), template_id (FK→inspection_templates CASCADE),
inspector_id (FK→users SET NULL), frequency ENUM('once','daily','weekly','monthly'), inspector_id (FK→users SET NULL), frequency ENUM('once','daily','weekly','monthly'),
next_due_date DATE, active BOOL, notes TEXT, created_by, created_at, next_due_date DATE, active BOOL, notes TEXT, created_by, created_at,
last_completed_at DATETIME, advance_notified BOOL, due_notified BOOL, overdue_notified BOOL last_completed_at DATETIME, advance_notified BOOL, due_notified BOOL, overdue_notified BOOL,
weekdays VARCHAR(20) nullable, -- Phase 43: CSV weekday ints, Mon=0, e.g. '0,2,4'
month_mode VARCHAR(20) nullable, -- Phase 43: 'day_of_month' | 'nth_weekday'
day_of_month SMALLINT nullable, -- Phase 43
nth_week SMALLINT nullable, -- Phase 43: 15, or -1 = last
nth_weekday SMALLINT nullable -- Phase 43: 06, Mon=0
inspections.scheduled_inspection_id FK→scheduled_inspections SET NULL ← Phase 36 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: **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. - 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. - 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 → phase39_area_public_token
→ phase40_auditor_role → phase40_auditor_role
→ phase41_internal_handler → 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 ### phase21_performance_indexes
@@ -1353,6 +1388,7 @@ timeout = 30
| 78 | **`PATCH /api/v1/issues/<id>/handler` allows the inspector on purpose — do NOT align it to the web form's admin/director/PM restriction** | The iPad lets the assigned inspector set "Handled By" from the field, scoped via `get_inspector_scope()` (403 if the issue's facility isn't contracted). This is a deliberate divergence from the web form. `_issue_payload()` must keep returning all handler fields (`handler_type`, `handler_label`, `facility_handler_*`, `vendor_*`, `internal_handler_name`, `internal_handler_contact`) or the iPad's "Handled By" panel silently blanks — same failure mode as rule 40. | | 78 | **`PATCH /api/v1/issues/<id>/handler` allows the inspector on purpose — do NOT align it to the web form's admin/director/PM restriction** | The iPad lets the assigned inspector set "Handled By" from the field, scoped via `get_inspector_scope()` (403 if the issue's facility isn't contracted). This is a deliberate divergence from the web form. `_issue_payload()` must keep returning all handler fields (`handler_type`, `handler_label`, `facility_handler_*`, `vendor_*`, `internal_handler_name`, `internal_handler_contact`) or the iPad's "Handled By" panel silently blanks — same failure mode as rule 40. |
| 79 | **`auditor` = `project_manager` access + issue management, minus delete — keep the two decorators distinct** | Auditor is added to `@project_manager_required` (PM baseline) and to every `project_manager` role check in routes/templates. Its *extra* issue powers (verify/bulk-verify/verification-queue) go through the separate `@issue_manager_required` (admin/director/auditor). Issue **delete** stays `@supervisor_required` — never add auditor there. When adding a new PM-level gate, include `auditor`; when adding a director-only or delete-level gate, do not. The three issue **delete** template gates (spaced `['admin', 'director']` in `issues/list.html` + `issues/view.html`) are deliberately left without auditor. Auditor is also in the `_ALLOWED_ROLES` set of every `app/api/*` module — a **new** API blueprint's `_ALLOWED_ROLES` must include `auditor` for PM parity. | | 79 | **`auditor` = `project_manager` access + issue management, minus delete — keep the two decorators distinct** | Auditor is added to `@project_manager_required` (PM baseline) and to every `project_manager` role check in routes/templates. Its *extra* issue powers (verify/bulk-verify/verification-queue) go through the separate `@issue_manager_required` (admin/director/auditor). Issue **delete** stays `@supervisor_required` — never add auditor there. When adding a new PM-level gate, include `auditor`; when adding a director-only or delete-level gate, do not. The three issue **delete** template gates (spaced `['admin', 'director']` in `issues/list.html` + `issues/view.html`) are deliberately left without auditor. Auditor is also in the `_ALLOWED_ROLES` set of every `app/api/*` module — a **new** API blueprint's `_ALLOWED_ROLES` must include `auditor` for PM parity. |
| 80 | **Assignee dropdowns are `director`/`inspector`/`auditor` (admin removed, auditor added)** | The issue/inspection assignee `<select>`s query `User.role.in_([...])` — admin was removed and auditor added (the inspection flag-issue list also keeps `project_manager`). These lists control who can be *assigned*, distinct from who can *edit*. The issue-update route (`issues.view`) defensively appends any current `assigned_to` who is not in the set (e.g. a legacy admin assignment) to `form.assigned_to.choices` so saving the form never silently unassigns them. Do not remove that guard. | | 80 | **Assignee dropdowns are `director`/`inspector`/`auditor` (admin removed, auditor added)** | The issue/inspection assignee `<select>`s query `User.role.in_([...])` — admin was removed and auditor added (the inspection flag-issue list also keeps `project_manager`). These lists control who can be *assigned*, distinct from who can *edit*. The issue-update route (`issues.view`) defensively appends any current `assigned_to` who is not in the set (e.g. a legacy admin assignment) to `form.assigned_to.choices` so saving the form never silently unassigns them. Do not remove that guard. |
| 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. |
| 81 | **Photo timestamp/geo overlay is burned at UPLOAD, never on `PATCH /issues/<id>/photos`** | That PATCH receives only path strings — the bytes are already in storage and the payload carries no capture metadata. Burning there would need a read-modify-write per key plus an overwrite-in-place primitive (`storage.save()` mints a NEW uuid key, and §22 requires key == DB path), and would risk a **double burn** since the endpoint is deliberately idempotent/retry-safe (rule 45). Stamp in `POST /photos/upload`, where the raw bytes + EXIF are in hand and each call writes exactly one already-stamped object. Stamping failures must always fall back to storing the ORIGINAL bytes — never lose a photo to a stamping bug. See §23. | | 81 | **Photo timestamp/geo overlay is burned at UPLOAD, never on `PATCH /issues/<id>/photos`** | That PATCH receives only path strings — the bytes are already in storage and the payload carries no capture metadata. Burning there would need a read-modify-write per key plus an overwrite-in-place primitive (`storage.save()` mints a NEW uuid key, and §22 requires key == DB path), and would risk a **double burn** since the endpoint is deliberately idempotent/retry-safe (rule 45). Stamp in `POST /photos/upload`, where the raw bytes + EXIF are in hand and each call writes exactly one already-stamped object. Stamping failures must always fall back to storing the ORIGINAL bytes — never lose a photo to a stamping bug. See §23. |
--- ---
+8
View File
@@ -42,6 +42,14 @@ def _scheduled_payload(s):
'inspector_id': s.inspector_id, 'inspector_id': s.inspector_id,
'frequency': s.frequency, 'frequency': s.frequency,
'frequency_label': s.frequency_label, 'frequency_label': s.frequency_label,
# phase43 recurrence detail. `recurrence_label` is the display string
# ("Weekly · Mon, Wed, Fri"); the raw fields let the iPad render its own.
'recurrence_label': s.recurrence_label,
'weekdays': s.weekday_list,
'month_mode': s.month_mode,
'day_of_month': s.day_of_month,
'nth_week': s.nth_week,
'nth_weekday': s.nth_weekday,
'next_due_date': s.next_due_date.isoformat() if s.next_due_date else None, 'next_due_date': s.next_due_date.isoformat() if s.next_due_date else None,
'is_overdue': s.is_overdue(), 'is_overdue': s.is_overdue(),
'notes': s.notes or None, 'notes': s.notes or None,
+145 -10
View File
@@ -19,13 +19,56 @@ The *_notified flags make each of those fire at most once per occurrence and
reset when a recurring schedule rolls forward. reset when a recurring schedule rolls forward.
""" """
from datetime import timedelta import calendar
from datetime import date, timedelta
from app import db from app import db
from app.utils.time_utils import now_eastern from app.utils.time_utils import now_eastern
FREQUENCY_CHOICES = ('once', 'daily', 'weekly', 'monthly') FREQUENCY_CHOICES = ('once', 'daily', 'weekly', 'monthly')
# Monthly recurrence styles (phase43). Stored as VARCHAR, not ENUM, so adding a
# style later needs no 3-step MySQL ENUM dance (CLAUDE.md rule 3).
MONTH_MODE_DAY = 'day_of_month' # "the 15th of every month"
MONTH_MODE_NTH = 'nth_weekday' # "the 2nd Tuesday of every month"
# Python weekday numbering: Monday=0 … Sunday=6 (matches date.weekday()).
WEEKDAY_NAMES = ('Monday', 'Tuesday', 'Wednesday', 'Thursday',
'Friday', 'Saturday', 'Sunday')
WEEKDAY_ABBREV = ('Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun')
# nth_week: 14 are literal, 5 means "5th (or last if the month is short)",
# -1 means "last" explicitly.
NTH_WEEK_LABELS = {1: '1st', 2: '2nd', 3: '3rd', 4: '4th', 5: '5th', -1: 'Last'}
def _last_day_of(year, month):
return calendar.monthrange(year, month)[1]
def _shift_month(year, month, n=1):
"""Return (year, month) shifted by *n* months."""
idx = year * 12 + (month - 1) + n
return idx // 12, idx % 12 + 1
def _nth_weekday_of(year, month, weekday, nth):
"""Date of the *nth* *weekday* in a month.
``nth == -1`` means the last one. A requested 5th occurrence that does not
exist falls back to the 4th, so every month yields a valid date.
"""
last = _last_day_of(year, month)
if nth == -1:
d = date(year, month, last)
return d - timedelta(days=(d.weekday() - weekday) % 7)
first = date(year, month, 1)
day = 1 + ((weekday - first.weekday()) % 7) + (nth - 1) * 7
while day > last:
day -= 7
return date(year, month, day)
class ScheduledInspection(db.Model): class ScheduledInspection(db.Model):
__tablename__ = 'scheduled_inspections' __tablename__ = 'scheduled_inspections'
@@ -45,6 +88,18 @@ class ScheduledInspection(db.Model):
active = db.Column(db.Boolean, nullable=False, default=True) active = db.Column(db.Boolean, nullable=False, default=True)
notes = db.Column(db.Text, nullable=True) notes = db.Column(db.Text, nullable=True)
# ── Recurrence detail (phase43) ──────────────────────────────────────────
# weekly : CSV of Python weekday ints, e.g. '0,2,4' = Mon/Wed/Fri.
# NULL/empty falls back to the legacy "every 7 days" behaviour.
# monthly : month_mode picks which pair of columns applies —
# MONTH_MODE_DAY → day_of_month; MONTH_MODE_NTH → nth_week + nth_weekday.
# NULL falls back to the legacy "same day next month" behaviour.
weekdays = db.Column(db.String(20), nullable=True)
month_mode = db.Column(db.String(20), nullable=True)
day_of_month = db.Column(db.SmallInteger, nullable=True)
nth_week = db.Column(db.SmallInteger, nullable=True)
nth_weekday = db.Column(db.SmallInteger, nullable=True)
created_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), created_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'),
nullable=True) nullable=True)
created_at = db.Column(db.DateTime, nullable=False, default=now_eastern) created_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
@@ -72,22 +127,102 @@ class ScheduledInspection(db.Model):
def frequency_label(self): def frequency_label(self):
return self.FREQUENCY_LABELS.get(self.frequency, self.frequency) return self.FREQUENCY_LABELS.get(self.frequency, self.frequency)
# ── Recurrence accessors ─────────────────────────────────────────────────
@property
def weekday_list(self):
"""Selected weekdays as a sorted list of ints (Mon=0). [] if unset."""
if not self.weekdays:
return []
out = set()
for part in str(self.weekdays).split(','):
part = part.strip()
if part.lstrip('-').isdigit() and 0 <= int(part) <= 6:
out.add(int(part))
return sorted(out)
def set_weekdays(self, values):
"""Store an iterable of weekday ints as the CSV column (None if empty)."""
clean = sorted({int(v) for v in (values or []) if 0 <= int(v) <= 6})
self.weekdays = ','.join(str(v) for v in clean) or None
@property
def recurrence_label(self):
"""Human summary of the recurrence rule, e.g. 'Weekly · Mon, Wed, Fri'."""
base = self.frequency_label
if self.frequency == 'weekly':
days = self.weekday_list
if days:
return f"{base} · {', '.join(WEEKDAY_ABBREV[d] for d in days)}"
elif self.frequency == 'monthly':
if self.month_mode == MONTH_MODE_NTH and self.nth_week and self.nth_weekday is not None:
nth = NTH_WEEK_LABELS.get(self.nth_week, str(self.nth_week))
return f'{base} · {nth} {WEEKDAY_NAMES[self.nth_weekday]}'
if self.day_of_month:
return f'{base} · day {self.day_of_month}'
return base
# ── Date arithmetic ──────────────────────────────────────────────────────
@staticmethod @staticmethod
def _add_interval(d, frequency): def _add_interval(d, frequency):
"""Return d advanced by one interval of the given frequency.""" """Return d advanced by one plain interval of *frequency*.
Fallback used when no day-of-week / day-of-month detail is configured
(legacy phase36 rows). Prefer :meth:`next_occurrence_after`.
"""
if frequency == 'daily': if frequency == 'daily':
return d + timedelta(days=1) return d + timedelta(days=1)
if frequency == 'weekly': if frequency == 'weekly':
return d + timedelta(weeks=1) return d + timedelta(weeks=1)
if frequency == 'monthly': if frequency == 'monthly':
# Add ~1 month by stepping 2831 days to the same day-of-month where possible. year, month = _shift_month(d.year, d.month, 1)
month = d.month + 1 return date(year, month, min(d.day, _last_day_of(year, month)))
year = d.year + (1 if month > 12 else 0)
month = 1 if month > 12 else month
day = min(d.day, 28) # clamp to avoid invalid dates (e.g. Feb 30)
return d.replace(year=year, month=month, day=day)
return d # 'once' has no next interval return d # 'once' has no next interval
def next_occurrence_after(self, d):
"""First occurrence strictly after date *d*, honouring the day rules."""
if self.frequency == 'weekly':
days = self.weekday_list
if days:
for step in range(1, 8):
cand = d + timedelta(days=step)
if cand.weekday() in days:
return cand
elif self.frequency == 'monthly':
year, month = _shift_month(d.year, d.month, 1)
if self.month_mode == MONTH_MODE_NTH and self.nth_week and self.nth_weekday is not None:
return _nth_weekday_of(year, month, self.nth_weekday, self.nth_week)
if self.day_of_month:
return date(year, month, min(self.day_of_month, _last_day_of(year, month)))
return self._add_interval(d, self.frequency)
def align_due_date(self, d):
"""Snap *d* forward to the first date on/after it that fits the rule.
Lets a manager pick any start date and still get, say, Mon/Wed/Fri:
picking a Tuesday for a Mon/Wed/Fri schedule yields that Wednesday.
"""
if self.frequency == 'weekly':
days = self.weekday_list
if days:
for step in range(0, 7):
cand = d + timedelta(days=step)
if cand.weekday() in days:
return cand
elif self.frequency == 'monthly':
if self.month_mode == MONTH_MODE_NTH and self.nth_week and self.nth_weekday is not None:
cand = _nth_weekday_of(d.year, d.month, self.nth_weekday, self.nth_week)
elif self.day_of_month:
cand = date(d.year, d.month,
min(self.day_of_month, _last_day_of(d.year, d.month)))
else:
return d
if cand < d:
return self.next_occurrence_after(cand)
return cand
return d
def is_overdue(self, today=None): def is_overdue(self, today=None):
today = today or now_eastern().date() today = today or now_eastern().date()
return self.active and self.next_due_date < today return self.active and self.next_due_date < today
@@ -102,10 +237,10 @@ class ScheduledInspection(db.Model):
return return
# Recurring: advance until the next due date is in the future. # Recurring: advance until the next due date is in the future.
today = now_eastern().date() today = now_eastern().date()
nxt = self._add_interval(self.next_due_date, self.frequency) nxt = self.next_occurrence_after(self.next_due_date)
guard = 0 guard = 0
while nxt <= today and guard < 400: while nxt <= today and guard < 400:
nxt = self._add_interval(nxt, self.frequency) nxt = self.next_occurrence_after(nxt)
guard += 1 guard += 1
self.next_due_date = nxt self.next_due_date = nxt
self.advance_notified = False self.advance_notified = False
+5
View File
@@ -361,8 +361,10 @@ def index():
# ── Scheduled inspections (phase36): upcoming / overdue ────────────── # ── Scheduled inspections (phase36): upcoming / overdue ──────────────
sched_upcoming = [] sched_upcoming = []
sched_overdue_count = 0 sched_overdue_count = 0
sched_open_inspections = {}
if not is_customer: if not is_customer:
from app.models.scheduled_inspection import ScheduledInspection from app.models.scheduled_inspection import ScheduledInspection
from app.routes.scheduled_inspections import _open_inspection_ids
_today = now.date() _today = now.date()
_sq = ScheduledInspection.query.filter_by(active=True) _sq = ScheduledInspection.query.filter_by(active=True)
if is_inspector: if is_inspector:
@@ -374,11 +376,14 @@ def index():
s for s in _all_sched s for s in _all_sched
if _today <= s.next_due_date <= _today + timedelta(days=7) if _today <= s.next_due_date <= _today + timedelta(days=7)
][:8] ][:8]
# Offer Continue (not a duplicate Start) where one is already underway.
sched_open_inspections = _open_inspection_ids(sched_upcoming)
return render_template( return render_template(
'dashboard.html', 'dashboard.html',
sched_upcoming = sched_upcoming, sched_upcoming = sched_upcoming,
sched_overdue_count = sched_overdue_count, sched_overdue_count = sched_overdue_count,
sched_open_inspections = sched_open_inspections,
submitted_this_week = submitted_this_week, submitted_this_week = submitted_this_week,
completed_today = completed_today, completed_today = completed_today,
open_issues = open_issues, open_issues = open_issues,
+69 -7
View File
@@ -22,7 +22,8 @@ from flask import (Blueprint, render_template, redirect, url_for, flash,
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import db from app import db
from app.models.scheduled_inspection import ScheduledInspection from app.models.scheduled_inspection import (ScheduledInspection,
MONTH_MODE_DAY, MONTH_MODE_NTH)
from app.models.facility import Facility from app.models.facility import Facility
from app.models.inspection import Inspection, InspectionTemplate from app.models.inspection import Inspection, InspectionTemplate
from app.models.project import Project from app.models.project import Project
@@ -79,6 +80,50 @@ def _notify_assignee(sched, reassigned=False):
) )
def _apply_recurrence(sched, form):
"""Copy the recurrence block for the chosen frequency onto *sched* and
clear the blocks that no longer apply, then snap next_due_date onto the
rule. Keeping the unused columns NULL means `recurrence_label` and the
date math never read stale settings after a frequency change."""
sched.frequency = form.frequency.data
if sched.frequency == 'weekly':
sched.set_weekdays(form.weekdays.data)
else:
sched.weekdays = None
if sched.frequency == 'monthly':
sched.month_mode = form.month_mode.data or MONTH_MODE_DAY
if sched.month_mode == MONTH_MODE_NTH:
sched.day_of_month = None
sched.nth_week = form.nth_week.data
sched.nth_weekday = form.nth_weekday.data
else:
sched.day_of_month = form.day_of_month.data
sched.nth_week = None
sched.nth_weekday = None
else:
sched.month_mode = sched.day_of_month = None
sched.nth_week = sched.nth_weekday = None
# Snap the picked date forward onto the first matching occurrence.
sched.next_due_date = sched.align_due_date(form.next_due_date.data)
def _open_inspection_ids(schedules):
"""{schedule_id: inspection_id} for schedules with an inspection already
in progress, so the UI offers Continue instead of a duplicate Start."""
ids = [s.id for s in schedules if s.id]
if not ids:
return {}
rows = (Inspection.query
.filter(Inspection.scheduled_inspection_id.in_(ids),
Inspection.status == 'in_progress')
.order_by(Inspection.id.desc())
.all())
return {r.scheduled_inspection_id: r.id for r in rows}
def _selected_project_id(form): def _selected_project_id(form):
"""Contract of the submitted facility (for restoring the selector on """Contract of the submitted facility (for restoring the selector on
re-render), or None.""" re-render), or None."""
@@ -110,7 +155,8 @@ def index():
).all() ).all()
return render_template('scheduled_inspections/list.html', return render_template('scheduled_inspections/list.html',
schedules=schedules, today=today) schedules=schedules, today=today,
open_inspections=_open_inspection_ids(schedules))
# ── Create ────────────────────────────────────────────────────────────────── # ── Create ──────────────────────────────────────────────────────────────────
@@ -129,17 +175,18 @@ def create():
facility_id = form.facility_id.data, facility_id = form.facility_id.data,
template_id = form.template_id.data, template_id = form.template_id.data,
inspector_id = form.inspector_id.data, inspector_id = form.inspector_id.data,
frequency = form.frequency.data,
next_due_date = form.next_due_date.data, next_due_date = form.next_due_date.data,
notes = (form.notes.data or '').strip() or None, notes = (form.notes.data or '').strip() or None,
active = form.active.data, active = form.active.data,
created_by = current_user.id, created_by = current_user.id,
) )
_apply_recurrence(sched, form)
db.session.add(sched) db.session.add(sched)
db.session.commit() db.session.commit()
log_action(ACTION_CREATE, 'ScheduledInspection', sched.id, log_action(ACTION_CREATE, 'ScheduledInspection', sched.id,
f'{sched.template.name} @ {sched.facility.name}', f'{sched.template.name} @ {sched.facility.name}',
f'freq={sched.frequency}; due={sched.next_due_date}; inspector={sched.inspector_id}') f'freq={sched.recurrence_label}; due={sched.next_due_date}; '
f'inspector={sched.inspector_id}')
logger.info('SCHED INSP | create | by=%s | id=%s', current_user.username, sched.id) logger.info('SCHED INSP | create | by=%s | id=%s', current_user.username, sched.id)
# Notify the assigned inspector immediately. # Notify the assigned inspector immediately.
@@ -166,20 +213,25 @@ def edit(schedule_id):
abort(404) abort(404)
form = ScheduledInspectionForm(obj=sched) form = ScheduledInspectionForm(obj=sched)
_populate_choices(form) _populate_choices(form)
if request.method == 'GET':
# obj= copies the raw CSV column into a multi-select field; hand it the
# parsed int list instead so the checkboxes pre-tick correctly.
form.weekdays.data = sched.weekday_list
form.month_mode.data = sched.month_mode or MONTH_MODE_DAY
if form.validate_on_submit(): if form.validate_on_submit():
old_inspector_id = sched.inspector_id old_inspector_id = sched.inspector_id
sched.facility_id = form.facility_id.data sched.facility_id = form.facility_id.data
sched.template_id = form.template_id.data sched.template_id = form.template_id.data
sched.inspector_id = form.inspector_id.data sched.inspector_id = form.inspector_id.data
sched.frequency = form.frequency.data
sched.next_due_date = form.next_due_date.data
sched.notes = (form.notes.data or '').strip() or None sched.notes = (form.notes.data or '').strip() or None
sched.active = form.active.data sched.active = form.active.data
_apply_recurrence(sched, form)
db.session.commit() db.session.commit()
log_action(ACTION_UPDATE, 'ScheduledInspection', sched.id, log_action(ACTION_UPDATE, 'ScheduledInspection', sched.id,
f'{sched.template.name} @ {sched.facility.name}', f'{sched.template.name} @ {sched.facility.name}',
f'freq={sched.frequency}; due={sched.next_due_date}; active={sched.active}') f'freq={sched.recurrence_label}; due={sched.next_due_date}; '
f'active={sched.active}')
# Notify the inspector if the assignment changed to them. # Notify the inspector if the assignment changed to them.
if sched.active and sched.inspector_id and sched.inspector_id != old_inspector_id: if sched.active and sched.inspector_id and sched.inspector_id != old_inspector_id:
@@ -242,6 +294,16 @@ def start(schedule_id):
flash('The template for this schedule has no form fields yet.', 'warning') flash('The template for this schedule has no form fields yet.', 'warning')
return redirect(url_for('scheduled_inspections.index')) return redirect(url_for('scheduled_inspections.index'))
# Already started but not submitted? Resume it rather than opening a second
# inspection against the same occurrence.
existing = (Inspection.query
.filter_by(scheduled_inspection_id=sched.id, status='in_progress')
.order_by(Inspection.id.desc())
.first())
if existing is not None:
flash('Resuming the inspection you already started for this schedule.', 'info')
return redirect(url_for('inspections.execute', inspection_id=existing.id))
inspection = Inspection( inspection = Inspection(
template_id = sched.template_id, template_id = sched.template_id,
facility_id = sched.facility_id, facility_id = sched.facility_id,
+9 -1
View File
@@ -48,7 +48,7 @@
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm table-hover mb-0 align-middle"> <table class="table table-sm table-hover mb-0 align-middle">
<thead class="table-light"> <thead class="table-light">
<tr><th>Facility</th><th>Template</th><th>Inspector</th><th>Due</th><th></th></tr> <tr><th>Facility</th><th>Template</th><th>Inspector</th><th>Repeats</th><th>Due</th><th></th></tr>
</thead> </thead>
<tbody> <tbody>
{% for s in sched_upcoming %} {% for s in sched_upcoming %}
@@ -56,13 +56,21 @@
<td>{{ s.facility.name if s.facility else '—' }}</td> <td>{{ s.facility.name if s.facility else '—' }}</td>
<td class="small">{{ s.template.name if s.template else '—' }}</td> <td class="small">{{ s.template.name if s.template else '—' }}</td>
<td class="small">{{ s.inspector.display_name if s.inspector else '—' }}</td> <td class="small">{{ s.inspector.display_name if s.inspector else '—' }}</td>
<td class="small text-muted">{{ s.recurrence_label }}</td>
<td class="small">{{ s.next_due_date.strftime('%b %d') }}</td> <td class="small">{{ s.next_due_date.strftime('%b %d') }}</td>
<td class="text-end"> <td class="text-end">
{# Start is shown only to the assignee — the inspection is theirs to do. #} {# Start is shown only to the assignee — the inspection is theirs to do. #}
{% if s.inspector_id and s.inspector_id == current_user.id %} {% if s.inspector_id and s.inspector_id == current_user.id %}
{% set open_id = sched_open_inspections.get(s.id) %}
{% if open_id %}
<a href="{{ url_for('inspections.execute', inspection_id=open_id) }}"
class="btn btn-sm btn-warning py-0" title="You already started this — resume it">
<i class="bi bi-pencil-square"></i> Continue</a>
{% else %}
<a href="{{ url_for('scheduled_inspections.start', schedule_id=s.id) }}" <a href="{{ url_for('scheduled_inspections.start', schedule_id=s.id) }}"
class="btn btn-sm btn-success py-0"><i class="bi bi-play-fill"></i> Start</a> class="btn btn-sm btn-success py-0"><i class="bi bi-play-fill"></i> Start</a>
{% endif %} {% endif %}
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -49,6 +49,64 @@
{{ form.next_due_date.label(class="form-label fw-semibold") }} {{ form.next_due_date.label(class="form-label fw-semibold") }}
{{ form.next_due_date(class="form-control", type="date") }} {{ form.next_due_date(class="form-control", type="date") }}
{% for e in form.next_due_date.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %} {% for e in form.next_due_date.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
<div class="form-text">Snapped forward to the first matching day.</div>
</div>
</div>
{# ── Weekly: which days of the week ──────────────────────────── #}
<div class="mb-3 p-3 rounded bg-light border" id="weekly_block" hidden>
<label class="form-label fw-semibold d-block">{{ form.weekdays.label.text }}</label>
<div class="d-flex flex-wrap gap-3">
{% for value, label in form.weekdays.choices %}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="weekdays"
id="weekday_{{ value }}" value="{{ value }}"
{% if form.weekdays.data and value in form.weekdays.data %}checked{% endif %}>
<label class="form-check-label" for="weekday_{{ value }}">{{ label[:3] }}</label>
</div>
{% endfor %}
</div>
{% for e in form.weekdays.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
<div class="form-text mb-0">
Pick every day the inspection recurs — e.g. Mon, Wed, Fri gives three
inspections a week. The due date rolls to the next selected day each
time one is submitted.
</div>
</div>
{# ── Monthly: day-of-month OR nth weekday ────────────────────── #}
<div class="mb-3 p-3 rounded bg-light border" id="monthly_block" hidden>
<label class="form-label fw-semibold d-block">{{ form.month_mode.label.text }}</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="month_mode"
id="month_mode_day" value="day_of_month"
{% if form.month_mode.data != 'nth_weekday' %}checked{% endif %}>
<label class="form-check-label" for="month_mode_day">On a day of the month</label>
</div>
<div class="ms-4 mb-2" id="dom_row">
<div class="input-group input-group-sm" style="max-width:16rem;">
<span class="input-group-text">Day</span>
{{ form.day_of_month(class="form-control", type="number", min=1, max=31,
placeholder="15") }}
</div>
{% for e in form.day_of_month.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
<div class="form-text mb-0">Months without that day use their last day.</div>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="month_mode"
id="month_mode_nth" value="nth_weekday"
{% if form.month_mode.data == 'nth_weekday' %}checked{% endif %}>
<label class="form-check-label" for="month_mode_nth">On a weekday of the month</label>
</div>
<div class="ms-4" id="nth_row">
<div class="d-flex gap-2 flex-wrap" style="max-width:24rem;">
{{ form.nth_week(class="form-select form-select-sm", style="max-width:7rem;") }}
{{ form.nth_weekday(class="form-select form-select-sm", style="max-width:11rem;") }}
</div>
{% for e in form.nth_week.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
<div class="form-text mb-0">e.g. the 2nd Tuesday of every month.</div>
</div> </div>
</div> </div>
@@ -129,5 +187,40 @@
setPlaceholder(); 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();
}());
</script> </script>
{% endblock %} {% endblock %}
@@ -43,7 +43,7 @@
<td><strong>{{ s.facility.name if s.facility else '—' }}</strong></td> <td><strong>{{ s.facility.name if s.facility else '—' }}</strong></td>
<td>{{ s.template.name if s.template else '—' }}</td> <td>{{ s.template.name if s.template else '—' }}</td>
<td>{{ s.inspector.display_name if s.inspector else '— Unassigned —' }}</td> <td>{{ s.inspector.display_name if s.inspector else '— Unassigned —' }}</td>
<td><span class="badge bg-secondary">{{ s.frequency_label }}</span></td> <td><span class="badge bg-secondary">{{ s.recurrence_label }}</span></td>
<td> <td>
{{ s.next_due_date.strftime('%b %d, %Y') }} {{ s.next_due_date.strftime('%b %d, %Y') }}
{% if overdue %} {% if overdue %}
@@ -62,11 +62,19 @@
<td class="text-end text-nowrap"> <td class="text-end text-nowrap">
{# Start is shown only to the assignee — the inspection is theirs to do. #} {# 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 %} {% if s.active and s.inspector_id and s.inspector_id == current_user.id %}
{% set open_id = open_inspections.get(s.id) %}
{% if open_id %}
<a href="{{ url_for('inspections.execute', inspection_id=open_id) }}"
class="btn btn-sm btn-warning" title="You already started this — resume it">
<i class="bi bi-pencil-square"></i> Continue
</a>
{% else %}
<a href="{{ url_for('scheduled_inspections.start', schedule_id=s.id) }}" <a href="{{ url_for('scheduled_inspections.start', schedule_id=s.id) }}"
class="btn btn-sm btn-success" title="Start this inspection"> class="btn btn-sm btn-success" title="Start this inspection">
<i class="bi bi-play-fill"></i> Start <i class="bi bi-play-fill"></i> Start
</a> </a>
{% endif %} {% endif %}
{% endif %}
{% if current_user.role in ['admin','director','project_manager','auditor'] %} {% if current_user.role in ['admin','director','project_manager','auditor'] %}
<a href="{{ url_for('scheduled_inspections.edit', schedule_id=s.id) }}" <a href="{{ url_for('scheduled_inspections.edit', schedule_id=s.id) }}"
class="btn btn-sm btn-outline-primary"><i class="bi bi-pencil"></i></a> class="btn btn-sm btn-outline-primary"><i class="bi bi-pencil"></i></a>
+46 -2
View File
@@ -2,7 +2,7 @@ from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed, MultipleFileField from flask_wtf.file import FileField, FileAllowed, MultipleFileField
from wtforms import (StringField, PasswordField, SelectField, TextAreaField, from wtforms import (StringField, PasswordField, SelectField, TextAreaField,
DecimalField, BooleanField, IntegerField, HiddenField, DecimalField, BooleanField, IntegerField, HiddenField,
RadioField, DateField) RadioField, DateField, SelectMultipleField)
from wtforms.validators import (DataRequired, Email, Length, EqualTo, from wtforms.validators import (DataRequired, Email, Length, EqualTo,
Optional, NumberRange, ValidationError) Optional, NumberRange, ValidationError)
from app.models.user import User from app.models.user import User
@@ -338,10 +338,54 @@ class ScheduledInspectionForm(FlaskForm):
('once', 'One-time'), ('daily', 'Daily'), ('once', 'One-time'), ('daily', 'Daily'),
('weekly', 'Weekly'), ('monthly', 'Monthly'), ('weekly', 'Weekly'), ('monthly', 'Monthly'),
], validators=[DataRequired()]) ], 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)]) notes = TextAreaField('Notes', validators=[Optional(), Length(max=1000)])
active = BooleanField('Active', default=True) active = BooleanField('Active', default=True)
# ── Recurrence detail (phase43) ──────────────────────────────────────────
# Only the block matching `frequency` is required; the rest is ignored and
# cleared on save. Shown/hidden client-side, enforced in validate() below.
weekdays = SelectMultipleField(
'Days of the Week', coerce=int, validators=[Optional()],
choices=[(i, n) for i, n in enumerate(
['Monday', 'Tuesday', 'Wednesday', 'Thursday',
'Friday', 'Saturday', 'Sunday'])],
)
month_mode = SelectField('Monthly Rule', validators=[Optional()], choices=[
('day_of_month', 'On a day of the month'),
('nth_weekday', 'On a weekday of the month'),
], default='day_of_month')
day_of_month = IntegerField(
'Day of Month', validators=[Optional(), NumberRange(min=1, max=31)])
nth_week = SelectField('Week', coerce=int, validators=[Optional()], choices=[
(1, '1st'), (2, '2nd'), (3, '3rd'), (4, '4th'), (5, '5th'), (-1, 'Last'),
], default=1)
nth_weekday = SelectField(
'Weekday', coerce=int, validators=[Optional()],
choices=[(i, n) for i, n in enumerate(
['Monday', 'Tuesday', 'Wednesday', 'Thursday',
'Friday', 'Saturday', 'Sunday'])],
default=0,
)
def validate(self, extra_validators=None):
"""Conditionally require the recurrence block for the chosen frequency."""
if not super().validate(extra_validators):
return False
ok = True
if self.frequency.data == 'weekly' and not self.weekdays.data:
self.weekdays.errors.append('Pick at least one day of the week.')
ok = False
elif self.frequency.data == 'monthly':
if self.month_mode.data == 'nth_weekday':
if not self.nth_week.data or self.nth_weekday.data is None:
self.nth_week.errors.append('Choose which weekday of the month.')
ok = False
elif not self.day_of_month.data:
self.day_of_month.errors.append('Enter a day of the month (131).')
ok = False
return ok
# ── Support Knowledge Base (phase38) ───────────────────────────────────────── # ── Support Knowledge Base (phase38) ─────────────────────────────────────────
@@ -0,0 +1,66 @@
"""phase43 — scheduled inspection day-of-week / day-of-month recurrence
Adds the recurrence-detail columns to `scheduled_inspections` so a weekly
schedule can name the weekdays it runs on (Mon/Wed/Fri) and a monthly schedule
can name either a day of the month ("the 15th") or an nth weekday ("the 2nd
Tuesday"):
weekdays VARCHAR(20) -- CSV of Python weekday ints, Mon=0, e.g. '0,2,4'
month_mode VARCHAR(20) -- 'day_of_month' | 'nth_weekday'
day_of_month SMALLINT -- 131, clamped to the month's last day
nth_week SMALLINT -- 15, or -1 for "last"
nth_weekday SMALLINT -- 06, Mon=0
All nullable with no backfill: existing phase36 rows keep NULLs and fall back to
the legacy "+7 days" / "same day next month" behaviour in
ScheduledInspection._add_interval(), so no schedule changes cadence on deploy.
`month_mode` is VARCHAR rather than ENUM so adding a recurrence style later
needs no 3-step MySQL ENUM migration (CLAUDE.md rule 3).
Uses INFORMATION_SCHEMA column-existence checks safe to re-run.
"""
revision = 'phase43_sched_recurrence'
down_revision = 'phase42_internal_contact'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
_COLUMNS = (
('weekdays', 'VARCHAR(20) NULL'),
('month_mode', 'VARCHAR(20) NULL'),
('day_of_month', 'SMALLINT NULL'),
('nth_week', 'SMALLINT NULL'),
('nth_weekday', 'SMALLINT NULL'),
)
def _column_exists(conn, table, column):
result = conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() "
"AND TABLE_NAME = :t AND COLUMN_NAME = :c"
), {"t": table, "c": column})
return result.scalar() > 0
def upgrade():
bind = op.get_bind()
for name, ddl in _COLUMNS:
if not _column_exists(bind, 'scheduled_inspections', name):
op.execute(sa.text(
f"ALTER TABLE scheduled_inspections ADD COLUMN {name} {ddl}"
))
def downgrade():
bind = op.get_bind()
for name, _ in reversed(_COLUMNS):
if _column_exists(bind, 'scheduled_inspections', name):
op.execute(sa.text(
f"ALTER TABLE scheduled_inspections DROP COLUMN {name}"
))