Jul 26 - Update scheduled inspection settings (weekly/monthly)
This commit is contained in:
@@ -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: 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
|
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. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: 1–4 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 28–31 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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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 (1–31).')
|
||||||
|
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 -- 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}"
|
||||||
|
))
|
||||||
Reference in New Issue
Block a user