Aug 4 - Update code to follow up - MT11

This commit is contained in:
2026-08-04 13:00:21 -04:00
parent ceb0b806af
commit 3c2489e289
10 changed files with 1468 additions and 49 deletions
+7 -5
View File
@@ -146,11 +146,13 @@ def _fulfill_schedule(inspection):
"""Roll the originating schedule forward. Caller commits. """Roll the originating schedule forward. Caller commits.
Mirrors routes/inspections.py exactly, including passing `_compute_next_run` Mirrors routes/inspections.py exactly, including passing `_compute_next_run`
as `next_run_fn`. That argument is NOT optional in practice: MT's as `next_run_fn`. As of phase46 that argument is accepted and ignored: the
`InspectionSchedule.fulfill()` leaves `next_run_at` untouched when it is cadence maths moved onto `InspectionSchedule.advance_due_date()`, which owns
omitted, so the schedule would stay permanently due and keep firing overdue the recurrence columns and the end-date boundary. Before phase46 omitting it
reminders. The deferred import mirrors the web route and avoids a module-load silently left `next_run_at` untouched and the schedule stayed permanently
cycle between the api and routes packages. due; the call is kept as-is so this file needs no behavioural change. The
deferred import mirrors the web route and avoids a module-load cycle between
the api and routes packages.
""" """
if not inspection.inspection_schedule_id: if not inspection.inspection_schedule_id:
return return
+11
View File
@@ -56,7 +56,18 @@ def _scheduled_payload(s):
'frequency': s.frequency, 'frequency': s.frequency,
'frequency_label': s.frequency_label, 'frequency_label': s.frequency_label,
'mode': s.mode, 'mode': s.mode,
# phase46 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_run_at.date().isoformat() if s.next_run_at else None, 'next_due_date': s.next_run_at.date().isoformat() if s.next_run_at else None,
# phase47. Additive: the iPad decodes explicit CodingKeys, so a build
# that predates this key ignores it rather than failing to decode.
'end_date': s.end_date.isoformat() if s.end_date else None,
'is_overdue': s.is_overdue(), 'is_overdue': s.is_overdue(),
'notes': s.notes or None, 'notes': s.notes or None,
} }
+327 -12
View File
@@ -27,12 +27,93 @@ phase43 adds the single-tenant "plan" semantics alongside that:
deactivates a one-time schedule or rolls a recurring one forward. deactivates a one-time schedule or rolls a recurring one forward.
`next_run_at` is the due datetime for both modes. `next_run_at` is the due datetime for both modes.
phase45/46/47 port the single-tenant recurrence + end-date model
(ST phase43 + phase44) onto this table without renaming anything:
phase45 frequency ENUM gains 'once', 'bi-annually' and 'annually'
phase46 weekdays / month_mode / day_of_month / nth_week / nth_weekday
phase47 end_date
`next_run_at` keeps its name, its DATETIME type and its index — it remains the
due datetime, and is ST's `next_due_date` by another name. All recurrence maths
happens on its DATE part; the TIME part is preserved across roll-forwards
(defaulting to 06:00, the hour `_compute_next_run()` has always used) so the
auto-mode cron keeps firing at the same time of day.
Two dates, deliberately distinct:
next_run_at — mutable state. The next occurrence. Rewritten by fulfill()
after every completed inspection.
end_date — fixed boundary. The last date an occurrence may fall on, set by
the manager and never rewritten. NULL = repeat indefinitely.
""" """
import calendar
from datetime import date, datetime, time, 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
# Every frequency this table accepts (phase45). 'bi-annually' is every 6 months.
FREQUENCY_CHOICES = ('once', 'daily', 'weekly', 'monthly',
'quarterly', 'bi-annually', 'annually')
# Recurring frequencies that advance by whole calendar months.
_MONTH_STEPS = {
'monthly': 1,
'quarterly': 3,
'bi-annually': 6,
'annually': 12,
}
# Monthly recurrence styles (phase46). Stored as VARCHAR, not ENUM, so adding a
# style later needs no 3-step MySQL ENUM migration.
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'}
# The hour auto-mode schedules have always been due at (see _compute_next_run()
# in app/routes/inspection_schedules.py). Used when a schedule has no
# next_run_at yet and therefore no time-of-day to preserve.
DEFAULT_RUN_HOUR = 6
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 InspectionSchedule(db.Model): class InspectionSchedule(db.Model):
__tablename__ = 'inspection_schedules' __tablename__ = 'inspection_schedules'
@@ -56,9 +137,11 @@ class InspectionSchedule(db.Model):
nullable=False nullable=False
) )
# daily | weekly | monthly | quarterly — mirrors InspectionTemplate.frequency # once | daily | weekly | monthly | quarterly | bi-annually | annually
# (phase45 — 'once', 'bi-annually' and 'annually' added; the original four
# values are unchanged, so no existing row is affected.)
frequency = db.Column( frequency = db.Column(
db.Enum('daily', 'weekly', 'monthly', 'quarterly'), db.Enum(*FREQUENCY_CHOICES),
nullable=False, default='weekly' nullable=False, default='weekly'
) )
active = db.Column(db.Boolean, nullable=False, default=True) active = db.Column(db.Boolean, nullable=False, default=True)
@@ -76,6 +159,30 @@ class InspectionSchedule(db.Model):
last_run_at = db.Column(db.DateTime, nullable=True) # last successful materialisation last_run_at = db.Column(db.DateTime, nullable=True) # last successful materialisation
next_run_at = db.Column(db.DateTime, nullable=True) # when the next inspection is due next_run_at = db.Column(db.DateTime, nullable=True) # when the next inspection is due
# ── Recurrence detail (phase46) ──────────────────────────────────────────
# 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 and the other month-stepping frequencies:
# 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 period" behaviour.
# Every pre-phase46 row keeps NULLs here and therefore keeps its exact
# current cadence.
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)
# ── End-date boundary (phase47) ──────────────────────────────────────────
# Fixed boundary set by the manager, never rewritten by the app — unlike
# next_run_at, which fulfill() advances after every completed inspection.
# NULL = repeat indefinitely, which is what every pre-phase47 row is. Only
# meaningful for recurring schedules; the create/edit routes force it to
# NULL when frequency == 'once'. Applies to BOTH modes: without it an auto
# schedule would keep materialising inspections past its boundary forever.
end_date = db.Column(db.Date, nullable=True)
# phase43 — plan mode bookkeeping # phase43 — plan mode bookkeeping
last_completed_at = db.Column(db.DateTime, nullable=True) last_completed_at = db.Column(db.DateTime, nullable=True)
# Per-occurrence reminder de-dup flags; reset when a recurring schedule rolls forward. # Per-occurrence reminder de-dup flags; reset when a recurring schedule rolls forward.
@@ -91,10 +198,13 @@ class InspectionSchedule(db.Model):
creator = db.relationship('User', foreign_keys=[created_by]) creator = db.relationship('User', foreign_keys=[created_by])
FREQUENCY_LABELS = { FREQUENCY_LABELS = {
'daily': 'Daily', 'once': 'One-time',
'weekly': 'Weekly', 'daily': 'Daily',
'monthly': 'Monthly', 'weekly': 'Weekly',
'quarterly': 'Quarterly', 'monthly': 'Monthly',
'quarterly': 'Quarterly',
'bi-annually': 'Every 6 months',
'annually': 'Annually',
} }
@property @property
@@ -113,18 +223,223 @@ class InspectionSchedule(db.Model):
today = today or now_eastern().date() today = today or now_eastern().date()
return self.next_run_at.date() < today return self.next_run_at.date() < today
# ── Recurrence accessors (phase46) ───────────────────────────────────────
@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 in _MONTH_STEPS:
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 (phase46) ────────────────────────────────────────────
@property
def run_time(self):
"""Time-of-day this schedule is due at.
Preserved across roll-forwards so an auto schedule keeps materialising
at the hour it always has. Falls back to DEFAULT_RUN_HOUR for a schedule
that has no next_run_at yet.
"""
if self.next_run_at is not None:
return self.next_run_at.time()
return time(hour=DEFAULT_RUN_HOUR)
def set_next_run_date(self, d):
"""Set next_run_at to date *d* keeping the current time-of-day."""
if d is None:
self.next_run_at = None
else:
self.next_run_at = datetime.combine(d, self.run_time)
@staticmethod
def _add_interval(d, frequency):
"""Return date *d* advanced by one plain interval of *frequency*.
Fallback used when no day-of-week / day-of-month detail is configured
(every pre-phase46 row). Prefer :meth:`next_occurrence_after`.
"""
if frequency == 'daily':
return d + timedelta(days=1)
if frequency == 'weekly':
return d + timedelta(weeks=1)
step = _MONTH_STEPS.get(frequency)
if step:
year, month = _shift_month(d.year, d.month, step)
return date(year, month, min(d.day, _last_day_of(year, month)))
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 in _MONTH_STEPS:
year, month = _shift_month(d.year, d.month, _MONTH_STEPS[self.frequency])
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 in _MONTH_STEPS:
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
# ── End-date boundary (phase47) ──────────────────────────────────────────
def is_within_end_date(self, d):
"""True if date *d* is on or before the end date (inclusive).
No end date means the schedule repeats indefinitely, so every date
qualifies.
"""
return self.end_date is None or d <= self.end_date
@property
def is_expired(self):
"""True once the end date has passed.
Independent of `active`: a schedule can be inactive because it expired
or because a manager paused it, and the list view distinguishes the two.
Compares against the *end date* rather than next_run_at, which may have
been advanced past the boundary by fulfill().
"""
if self.end_date is None:
return False
return self.end_date < now_eastern().date()
def expire_if_past_end_date(self, today=None):
"""Deactivate a schedule whose end date has passed. Caller commits.
Returns True if this call changed anything. Needed because a schedule
can reach its end date *without ever being completed* — fulfill() never
runs, so the boundary would otherwise be checked nowhere and the cron
would keep materialising (auto) or re-alerting as overdue (plan)
forever. Called from the /run cron endpoint.
"""
today = today or now_eastern().date()
if self.active and self.end_date is not None and self.end_date < today:
self.active = False
return True
return False
# ── Roll-forward ─────────────────────────────────────────────────────────
def advance_due_date(self, now=None):
"""Move next_run_at to the first occurrence after today. Caller commits.
Shared by fulfill() (plan mode, the inspector submitted the inspection)
and the cron materialiser (auto mode, an occurrence was produced), so
both modes obey the same recurrence rules and the same end-date
boundary. A 'once' schedule deactivates and its due date is left where
it is. Returns the new due date, or None for 'once'.
Advances from the current due date rather than from now, so a Mon/Wed/Fri
schedule dealt with late stays on Mon/Wed/Fri.
"""
now = now or now_eastern()
if self.frequency == 'once':
self.active = False
return None
today = now.date()
base = self.due_date or today
nxt = self.next_occurrence_after(base)
guard = 0
# Guard stops a misconfigured row spinning; 400 covers a daily schedule
# left untouched for over a year.
while nxt <= today and guard < 400:
nxt = self.next_occurrence_after(nxt)
guard += 1
self.set_next_run_date(nxt)
# Past the manager's boundary: this was the last occurrence. next_run_at
# is left at the computed value rather than clamped, so the row still
# shows which occurrence it stopped before.
if not self.is_within_end_date(nxt):
self.active = False
return nxt
def fulfill(self, next_run_fn=None): def fulfill(self, next_run_fn=None):
"""Mark this occurrence complete. Caller commits. """Mark this occurrence complete. Caller commits.
Recurring schedules roll their due date forward past today and reset the One-time schedules deactivate. Recurring ones roll their due date
reminder flags; MT has no 'once' frequency, so a schedule stays active. forward past today, honouring the phase46 day rules, and reset the
`next_run_fn(frequency, from_dt)` computes the next due datetime — the reminder flags. A recurring schedule whose next occurrence would fall
route passes `_compute_next_run` so the cadence math lives in one place. past its end date deactivates instead (phase47).
`next_run_fn` is accepted and IGNORED, retained only so the existing
`fulfill(next_run_fn=_compute_next_run)` call sites in
routes/inspections.py and api/inspections.py keep working unchanged.
Before phase46 the cadence maths lived in the route and omitting this
argument silently left next_run_at untouched — leaving the schedule
perpetually due. The maths now lives here, on the object that owns the
recurrence columns, so that failure mode is unreachable.
""" """
now = now_eastern() now = now_eastern()
self.last_completed_at = now self.last_completed_at = now
if next_run_fn is not None:
self.next_run_at = next_run_fn(self.frequency, now) self.advance_due_date(now)
if not self.active:
# 'once', or the roll-forward crossed the end date. Either way this
# was the last occurrence — leave the reminder flags set so nothing
# re-fires against a closed schedule.
return
self.advance_notified = False self.advance_notified = False
self.due_notified = False self.due_notified = False
self.overdue_notified = False self.overdue_notified = False
+228 -26
View File
@@ -35,7 +35,10 @@ 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, csrf from app import db, csrf
from app.models.inspection_schedule import InspectionSchedule from app.models.inspection_schedule import (InspectionSchedule,
FREQUENCY_CHOICES,
MONTH_MODE_DAY, MONTH_MODE_NTH,
DEFAULT_RUN_HOUR)
from app.models.inspection import Inspection, InspectionTemplate from app.models.inspection import Inspection, InspectionTemplate
from app.models.facility import Facility, Area from app.models.facility import Facility, Area
from app.models.user import User from app.models.user import User
@@ -49,8 +52,9 @@ logger = logging.getLogger(__name__)
bp = Blueprint('inspection_schedules', __name__, url_prefix='/inspection-schedules') bp = Blueprint('inspection_schedules', __name__, url_prefix='/inspection-schedules')
_FREQUENCIES = ('daily', 'weekly', 'monthly', 'quarterly') _FREQUENCIES = FREQUENCY_CHOICES
_MODES = ('auto', 'plan') _MODES = ('auto', 'plan')
_MONTH_MODES = (MONTH_MODE_DAY, MONTH_MODE_NTH)
# ── Helpers ─────────────────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────────────────
@@ -58,20 +62,126 @@ _MODES = ('auto', 'plan')
def _compute_next_run(frequency: str, from_dt: datetime = None) -> datetime: def _compute_next_run(frequency: str, from_dt: datetime = None) -> datetime:
"""Return the next due datetime for a given cadence, at 06:00 local. """Return the next due datetime for a given cadence, at 06:00 local.
Monthly/quarterly advance by calendar months (targeting the same day-of-month Plain interval maths with no day-of-week / day-of-month detail. Used only to
is avoided we simply add 30/90 days, which is predictable and never raises seed a schedule's FIRST occurrence when the manager does not pick a start
the datetime.replace(month=13) ValueError). date; every subsequent roll-forward goes through
InspectionSchedule.fulfill(), which honours the phase46 recurrence columns.
Monthly and longer cadences advance by whole days rather than calendar
months here, which is predictable and never raises the
datetime.replace(month=13) ValueError. Kept as-is for the original four
frequencies so no existing schedule's first-run maths changes.
""" """
now = from_dt or now_eastern() now = from_dt or now_eastern()
if frequency == 'daily': if frequency == 'once':
base = now
elif frequency == 'daily':
base = now + timedelta(days=1) base = now + timedelta(days=1)
elif frequency == 'weekly': elif frequency == 'weekly':
base = now + timedelta(weeks=1) base = now + timedelta(weeks=1)
elif frequency == 'monthly': elif frequency == 'monthly':
base = now + timedelta(days=30) base = now + timedelta(days=30)
elif frequency == 'bi-annually':
base = now + timedelta(days=182)
elif frequency == 'annually':
base = now + timedelta(days=365)
else: # quarterly else: # quarterly
base = now + timedelta(days=90) base = now + timedelta(days=90)
return base.replace(hour=6, minute=0, second=0, microsecond=0) return base.replace(hour=DEFAULT_RUN_HOUR, minute=0, second=0, microsecond=0)
def _parse_date(raw):
"""Parse an ISO date from a form field. Returns None for blank/invalid."""
raw = (raw or '').strip()
if not raw:
return None
try:
return datetime.strptime(raw, '%Y-%m-%d').date()
except ValueError:
return None
def _recurrence_errors(form, frequency):
"""Validation messages for the recurrence block of *frequency* (phase46).
Only the block matching the chosen frequency is checked; the others are
ignored here and cleared on save by _apply_recurrence().
"""
errors = []
if frequency == 'weekly':
if not form.getlist('weekdays'):
errors.append('Pick at least one day of the week.')
elif frequency in ('monthly', 'quarterly', 'bi-annually', 'annually'):
month_mode = form.get('month_mode') or MONTH_MODE_DAY
if month_mode not in _MONTH_MODES:
errors.append('Invalid monthly rule.')
elif month_mode == MONTH_MODE_NTH:
if not form.get('nth_week', type=int) or form.get('nth_weekday', type=int) is None:
errors.append('Choose which weekday of the month.')
else:
dom = form.get('day_of_month', type=int)
if not dom or not 1 <= dom <= 31:
errors.append('Enter a day of the month (131).')
return errors
def _apply_recurrence(sched, form, frequency, due_date):
"""Copy the recurrence block for *frequency* onto *sched*, clear the blocks
that no longer apply, set the end date, then snap the due date onto the rule.
Keeping the unused columns NULL means recurrence_label and the date maths
never read stale settings after a frequency change. Caller commits.
"""
sched.frequency = frequency
if frequency == 'weekly':
sched.set_weekdays([int(v) for v in form.getlist('weekdays') if v.lstrip('-').isdigit()])
else:
sched.weekdays = None
if frequency in ('monthly', 'quarterly', 'bi-annually', 'annually'):
sched.month_mode = form.get('month_mode') or MONTH_MODE_DAY
if sched.month_mode == MONTH_MODE_NTH:
sched.day_of_month = None
sched.nth_week = form.get('nth_week', type=int)
sched.nth_weekday = form.get('nth_weekday', type=int)
else:
sched.day_of_month = form.get('day_of_month', type=int)
sched.nth_week = None
sched.nth_weekday = None
else:
sched.month_mode = sched.day_of_month = None
sched.nth_week = sched.nth_weekday = None
# End date (phase47) — a boundary, not a cadence setting. A one-time
# schedule has none: it ends by deactivating when it is completed.
sched.end_date = _parse_date(form.get('end_date')) if frequency != 'once' else None
# Snap the picked date forward onto the first matching occurrence.
sched.set_next_run_date(sched.align_due_date(due_date))
def _end_date_errors(sched, form, frequency):
"""End-date validation (phase47), run AFTER _apply_recurrence().
Two separate rejections:
* an end date on a one-time schedule meaningless, and silently dropping
it would hide the mistake;
* an aligned first occurrence past the end date. align_due_date() can push
the picked date forward onto the rule (pick a Tuesday for a Mon/Wed/Fri
schedule and the first occurrence is Wednesday), so an end date that
looked valid against the picked date can still leave the schedule with
no occurrence it is ever allowed to run.
"""
errors = []
raw_end = _parse_date(form.get('end_date'))
if raw_end and frequency == 'once':
errors.append('A one-time schedule has no end date — '
'it closes when it is completed.')
elif raw_end and sched.due_date and not sched.is_within_end_date(sched.due_date):
errors.append(f'With this recurrence the first occurrence falls on '
f'{sched.due_date:%b %d, %Y}, after the end date.')
return errors
def _active_inspectors(): def _active_inspectors():
@@ -204,6 +314,7 @@ def create():
errors.append('Invalid frequency.') errors.append('Invalid frequency.')
if mode not in _MODES: if mode not in _MODES:
errors.append('Invalid mode.') errors.append('Invalid mode.')
errors.extend(_recurrence_errors(request.form, frequency))
if errors: if errors:
for e in errors: for e in errors:
@@ -211,6 +322,7 @@ def create():
return render_template('inspection_schedules/form.html', return render_template('inspection_schedules/form.html',
templates=templates, facilities=facilities, templates=templates, facilities=facilities,
inspectors=inspectors, frequencies=_FREQUENCIES, inspectors=inspectors, frequencies=_FREQUENCIES,
frequency_labels=InspectionSchedule.FREQUENCY_LABELS,
title='New Inspection Schedule') title='New Inspection Schedule')
schedule = InspectionSchedule( schedule = InspectionSchedule(
@@ -225,13 +337,33 @@ def create():
active = True, active = True,
created_by = current_user.id, created_by = current_user.id,
created_at = now_eastern(), created_at = now_eastern(),
# Seeded here so run_time has a time-of-day to preserve;
# _apply_recurrence() below rewrites the DATE part.
next_run_at = _compute_next_run(frequency), next_run_at = _compute_next_run(frequency),
) )
# Blank start date keeps the pre-phase46 behaviour exactly: the first
# occurrence lands one interval from now. A picked date wins.
start_date = _parse_date(request.form.get('next_due_date')) \
or schedule.next_run_at.date()
_apply_recurrence(schedule, request.form, frequency, start_date)
end_errors = _end_date_errors(schedule, request.form, frequency)
if end_errors:
# schedule was never added to the session — nothing to roll back.
for e in end_errors:
flash(e, 'warning')
return render_template('inspection_schedules/form.html',
templates=templates, facilities=facilities,
inspectors=inspectors, frequencies=_FREQUENCIES,
frequency_labels=InspectionSchedule.FREQUENCY_LABELS,
title='New Inspection Schedule')
db.session.add(schedule) db.session.add(schedule)
db.session.commit() db.session.commit()
log_action(ACTION_CREATE, 'InspectionSchedule', schedule.id, schedule.name, log_action(ACTION_CREATE, 'InspectionSchedule', schedule.id, schedule.name,
f'frequency={frequency}; mode={mode}; template_id={template_id}; ' f'frequency={schedule.recurrence_label}; mode={mode}; '
f'facility_id={facility_id}') f'due={schedule.due_date}; end={schedule.end_date or ""}; '
f'template_id={template_id}; facility_id={facility_id}')
# phase43: tell the inspector it's theirs (plan mode has no materialised # phase43: tell the inspector it's theirs (plan mode has no materialised
# inspection to announce itself). # inspection to announce itself).
_notify_assignee(schedule) _notify_assignee(schedule)
@@ -242,6 +374,7 @@ def create():
return render_template('inspection_schedules/form.html', return render_template('inspection_schedules/form.html',
templates=templates, facilities=facilities, templates=templates, facilities=facilities,
inspectors=inspectors, frequencies=_FREQUENCIES, inspectors=inspectors, frequencies=_FREQUENCIES,
frequency_labels=InspectionSchedule.FREQUENCY_LABELS,
title='New Inspection Schedule') title='New Inspection Schedule')
@@ -262,6 +395,20 @@ def edit(schedule_id):
inspector_id = request.form.get('inspector_id', type=int) inspector_id = request.form.get('inspector_id', type=int)
frequency = request.form.get('frequency', schedule.frequency) frequency = request.form.get('frequency', schedule.frequency)
if frequency not in _FREQUENCIES:
frequency = schedule.frequency
errors = _recurrence_errors(request.form, frequency)
if errors:
db.session.rollback()
for e in errors:
flash(e, 'warning')
return render_template('inspection_schedules/form.html',
schedule=schedule, templates=templates,
facilities=facilities, inspectors=inspectors,
frequencies=_FREQUENCIES,
frequency_labels=InspectionSchedule.FREQUENCY_LABELS,
title='Edit Inspection Schedule')
if template_id and db.session.get(InspectionTemplate, template_id): if template_id and db.session.get(InspectionTemplate, template_id):
schedule.template_id = template_id schedule.template_id = template_id
if facility_id and db.session.get(Facility, facility_id): if facility_id and db.session.get(Facility, facility_id):
@@ -269,23 +416,53 @@ def edit(schedule_id):
if inspector_id and db.session.get(User, inspector_id): if inspector_id and db.session.get(User, inspector_id):
schedule.inspector_id = inspector_id schedule.inspector_id = inspector_id
schedule.area_id = request.form.get('area_id', type=int) or None schedule.area_id = request.form.get('area_id', type=int) or None
if frequency in _FREQUENCIES:
schedule.frequency = frequency
mode = request.form.get('mode', schedule.mode) mode = request.form.get('mode', schedule.mode)
if mode in _MODES: if mode in _MODES:
schedule.mode = mode schedule.mode = mode
schedule.notes = request.form.get('notes', '').strip() or None schedule.notes = request.form.get('notes', '').strip() or None
schedule.active = bool(request.form.get('active')) schedule.active = bool(request.form.get('active'))
# Recompute the next run from now against the (possibly changed) cadence.
schedule.next_run_at = _compute_next_run(schedule.frequency) # ── Due date (phase46 root-cause fix) ─────────────────────────────
# This route used to do `next_run_at = _compute_next_run(frequency)`
# unconditionally, recomputing the due date from *now* on every save.
# Renaming a schedule or editing its notes therefore silently threw
# away the manager's chosen due date and reset every reminder — which
# with an end date and a day-of-week rule would also skip occurrences.
# The due date is now only rewritten when the manager picks a new one,
# or when the recurrence rule no longer fits the existing one (in which
# case align_due_date() snaps it forward to the nearest valid date).
old_due = schedule.due_date
target = (_parse_date(request.form.get('next_due_date'))
or old_due
or _compute_next_run(frequency).date())
_apply_recurrence(schedule, request.form, frequency, target)
end_errors = _end_date_errors(schedule, request.form, frequency)
if end_errors:
# schedule is persistent and already mutated — discard the pending
# changes before re-rendering so nothing leaks out on next flush.
db.session.rollback()
for e in end_errors:
flash(e, 'warning')
return render_template('inspection_schedules/form.html',
schedule=schedule, templates=templates,
facilities=facilities, inspectors=inspectors,
frequencies=_FREQUENCIES,
frequency_labels=InspectionSchedule.FREQUENCY_LABELS,
title='Edit Inspection Schedule')
# New occurrence -> the previous occurrence's reminders no longer apply. # New occurrence -> the previous occurrence's reminders no longer apply.
schedule.advance_notified = False # Unchanged due date -> keep the flags, or every unrelated edit would
schedule.due_notified = False # re-send the advance/due reminder the inspector already received.
schedule.overdue_notified = False if schedule.due_date != old_due:
schedule.advance_notified = False
schedule.due_notified = False
schedule.overdue_notified = False
db.session.commit() db.session.commit()
log_action(ACTION_UPDATE, 'InspectionSchedule', schedule.id, schedule.name, log_action(ACTION_UPDATE, 'InspectionSchedule', schedule.id, schedule.name,
f'frequency={schedule.frequency}; mode={schedule.mode}; ' f'frequency={schedule.recurrence_label}; mode={schedule.mode}; '
f'due={schedule.due_date}; end={schedule.end_date or ""}; '
f'active={schedule.active}') f'active={schedule.active}')
# phase43: notify on (re)assignment to a different inspector. # phase43: notify on (re)assignment to a different inspector.
@@ -298,6 +475,7 @@ def edit(schedule_id):
return render_template('inspection_schedules/form.html', schedule=schedule, return render_template('inspection_schedules/form.html', schedule=schedule,
templates=templates, facilities=facilities, templates=templates, facilities=facilities,
inspectors=inspectors, frequencies=_FREQUENCIES, inspectors=inspectors, frequencies=_FREQUENCIES,
frequency_labels=InspectionSchedule.FREQUENCY_LABELS,
title='Edit Inspection Schedule') title='Edit Inspection Schedule')
@@ -328,10 +506,13 @@ def run_now(schedule_id):
now = now_eastern() now = now_eastern()
inspection = _materialise(schedule, now) inspection = _materialise(schedule, now)
schedule.last_run_at = now schedule.last_run_at = now
schedule.next_run_at = _compute_next_run(schedule.frequency, now) # phase46/47: honours the day rules, the end-date boundary and 'once'
# (which deactivates), instead of the old flat interval.
schedule.advance_due_date(now)
db.session.commit() db.session.commit()
log_action(ACTION_UPDATE, 'InspectionSchedule', schedule.id, schedule.name, log_action(ACTION_UPDATE, 'InspectionSchedule', schedule.id, schedule.name,
f'manual run_now by {current_user.username}; inspection_id={inspection.id}') f'manual run_now by {current_user.username}; inspection_id={inspection.id}; '
f'next_due={schedule.due_date}; active={schedule.active}')
flash(f'Inspection created from "{schedule.name}". It is now in the inspector\'s queue.', flash(f'Inspection created from "{schedule.name}". It is now in the inspector\'s queue.',
'success') 'success')
return redirect(url_for('inspection_schedules.index')) return redirect(url_for('inspection_schedules.index'))
@@ -402,8 +583,28 @@ def run():
logger.warning('INSPECTION SCHEDULES RUN REJECTED | bad/missing token') logger.warning('INSPECTION SCHEDULES RUN REJECTED | bad/missing token')
return jsonify({'ok': False, 'error': 'unauthorized'}), 403 return jsonify({'ok': False, 'error': 'unauthorized'}), 403
now = now_eastern() now = now_eastern()
today = now.date()
schedules = InspectionSchedule.query.filter_by(active=True).all() schedules = InspectionSchedule.query.filter_by(active=True).all()
# ── Expiry sweep (phase47), BEFORE any materialisation or reminder work ──
# advance_due_date() closes out a schedule that reaches its boundary by
# producing an occurrence; this covers the one that reaches it without ever
# doing so — otherwise an auto schedule would keep materialising and a plan
# schedule would keep re-alerting as overdue, indefinitely.
expired = 0
live = []
for s in schedules:
if s.expire_if_past_end_date(today):
expired += 1
logger.info('INSPECTION SCHEDULE EXPIRED | schedule_id=%s | end_date=%s',
s.id, s.end_date)
else:
live.append(s)
if expired:
db.session.commit()
schedules = live
# Only 'auto' schedules materialise. 'plan' schedules wait for the inspector # Only 'auto' schedules materialise. 'plan' schedules wait for the inspector
# to click Start; they get reminders instead (below). # to click Start; they get reminders instead (below).
auto = [s for s in schedules if s.mode != 'plan'] auto = [s for s in schedules if s.mode != 'plan']
@@ -414,23 +615,24 @@ def run():
try: try:
inspection = _materialise(schedule, now) inspection = _materialise(schedule, now)
schedule.last_run_at = now schedule.last_run_at = now
schedule.next_run_at = _compute_next_run(schedule.frequency, now) schedule.advance_due_date(now)
created += 1 created += 1
logger.info('INSPECTION SCHEDULE MATERIALISED | schedule_id=%s | inspection_id=%s', logger.info('INSPECTION SCHEDULE MATERIALISED | schedule_id=%s | inspection_id=%s',
schedule.id, inspection.id) schedule.id, inspection.id)
except Exception as exc: except Exception as exc:
# Advance next_run_at anyway so one broken schedule can't wedge the # Advance the due date anyway so one broken schedule can't wedge the
# whole cron run on every subsequent tick. # whole cron run on every subsequent tick.
schedule.next_run_at = _compute_next_run(schedule.frequency, now) schedule.advance_due_date(now)
logger.error('INSPECTION SCHEDULE FAILED | schedule_id=%s | error=%s', logger.error('INSPECTION SCHEDULE FAILED | schedule_id=%s | error=%s',
schedule.id, exc) schedule.id, exc)
db.session.commit() db.session.commit()
sent = _dispatch_reminders([s for s in schedules if s.mode == 'plan'], now) sent = _dispatch_reminders([s for s in schedules if s.mode == 'plan'], now)
logger.info('INSPECTION SCHEDULES CRON | due=%s | created=%s | reminders=%s', logger.info('INSPECTION SCHEDULES CRON | expired=%s | due=%s | created=%s | reminders=%s',
len(due), created, sent) expired, len(due), created, sent)
return jsonify({'ok': True, 'due': len(due), 'created': created, 'reminders': sent}) return jsonify({'ok': True, 'expired': expired, 'due': len(due),
'created': created, 'reminders': sent})
def _dispatch_reminders(plans, now): def _dispatch_reminders(plans, now):
+140 -4
View File
@@ -29,16 +29,109 @@
</div> </div>
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<label class="form-label">Frequency</label> <label class="form-label">Frequency</label>
<select name="frequency" class="form-select" required> <select name="frequency" id="frequencySelect" class="form-select" required>
{% for f in frequencies %} {% for f in frequencies %}
<option value="{{ f }}" <option value="{{ f }}"
{{ 'selected' if (schedule and schedule.frequency == f) or (not schedule and f == 'weekly') }}> {{ 'selected' if (schedule and schedule.frequency == f) or (not schedule and f == 'weekly') }}>
{{ f|title }}</option> {{ frequency_labels.get(f, f|title) }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
</div> </div>
{# ── Recurrence detail (phase46) ────────────────────────────────────
Only the block matching the chosen frequency is shown; the server
validates the same block and clears the others on save. #}
<div class="row" id="weeklyBlock" style="display:none;">
<div class="col-12 mb-3">
<label class="form-label">Days of the Week</label>
<div class="d-flex flex-wrap gap-3">
{% set picked = schedule.weekday_list if schedule else [] %}
{% for i, day in [(0,'Mon'),(1,'Tue'),(2,'Wed'),(3,'Thu'),(4,'Fri'),(5,'Sat'),(6,'Sun')] %}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="weekdays"
value="{{ i }}" id="wd{{ i }}" {{ 'checked' if i in picked }}>
<label class="form-check-label" for="wd{{ i }}">{{ day }}</label>
</div>
{% endfor %}
</div>
<div class="form-text">
Leave the start date on any day — it snaps forward to the first
day you pick here.
</div>
</div>
</div>
<div class="row" id="monthlyBlock" style="display:none;">
<div class="col-md-4 mb-3">
<label class="form-label">Rule</label>
<select name="month_mode" id="monthModeSelect" class="form-select">
<option value="day_of_month"
{{ 'selected' if not schedule or schedule.month_mode != 'nth_weekday' }}>
On a day of the month</option>
<option value="nth_weekday"
{{ 'selected' if schedule and schedule.month_mode == 'nth_weekday' }}>
On a weekday of the month</option>
</select>
</div>
<div class="col-md-4 mb-3" id="dayOfMonthField">
<label class="form-label">Day of Month</label>
<input type="number" name="day_of_month" class="form-control"
min="1" max="31"
value="{{ schedule.day_of_month if schedule and schedule.day_of_month else '' }}">
<div class="form-text">Clamped to the last day in shorter months.</div>
</div>
<div class="col-md-4 mb-3" id="nthWeekdayField">
<div class="row g-2">
<div class="col-6">
<label class="form-label">Week</label>
<select name="nth_week" class="form-select">
{% for v, lbl in [(1,'1st'),(2,'2nd'),(3,'3rd'),(4,'4th'),(5,'5th'),(-1,'Last')] %}
<option value="{{ v }}"
{{ 'selected' if schedule and schedule.nth_week == v }}>{{ lbl }}</option>
{% endfor %}
</select>
</div>
<div class="col-6">
<label class="form-label">Weekday</label>
<select name="nth_weekday" class="form-select">
{% for i, day in [(0,'Monday'),(1,'Tuesday'),(2,'Wednesday'),(3,'Thursday'),(4,'Friday'),(5,'Saturday'),(6,'Sunday')] %}
<option value="{{ i }}"
{{ 'selected' if schedule and schedule.nth_weekday == i }}>{{ day }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" id="dueDateLabel">
{{ 'Next Due Date' if schedule else 'Start Date' }}
</label>
<input type="date" name="next_due_date" class="form-control"
value="{{ schedule.due_date.isoformat() if schedule and schedule.due_date else '' }}">
<div class="form-text">
{% if schedule %}
When the next occurrence is due. Leave unchanged and saving will
not move it.
{% else %}
Leave blank to start one full period from now.
{% endif %}
</div>
</div>
<div class="col-md-6 mb-3" id="endDateBlock">
<label class="form-label">End Date <span class="text-muted small">(optional)</span></label>
<input type="date" name="end_date" class="form-control"
value="{{ schedule.end_date.isoformat() if schedule and schedule.end_date else '' }}">
<div class="form-text">
Last date this schedule may produce an occurrence. Blank = repeats
indefinitely.
</div>
</div>
</div>
<div class="row"> <div class="row">
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<label class="form-label">Mode</label> <label class="form-label">Mode</label>
@@ -103,12 +196,15 @@
<label class="form-check-label" for="activeSwitch">Active</label> <label class="form-check-label" for="activeSwitch">Active</label>
</div> </div>
<p class="text-muted small"> <p class="text-muted small">
Saving recomputes the next {{ 'due date' if schedule.mode == 'plan' else 'run' }} Current {{ 'due date' if schedule.mode == 'plan' else 'run' }}:
from now, and resets this occurrence's reminders. Currently:
{{ schedule.next_run_at.strftime('%Y-%m-%d %H:%M') if schedule.next_run_at else '—' }} {{ schedule.next_run_at.strftime('%Y-%m-%d %H:%M') if schedule.next_run_at else '—' }}
{% if schedule.last_completed_at %} {% if schedule.last_completed_at %}
· last completed {{ schedule.last_completed_at.strftime('%Y-%m-%d %H:%M') }} · last completed {{ schedule.last_completed_at.strftime('%Y-%m-%d %H:%M') }}
{% endif %} {% endif %}
{% if schedule.end_date %}· ends {{ schedule.end_date.strftime('%Y-%m-%d') }}{% endif %}
<br>
Reminders for this occurrence are only reset if the due date actually
moves, so renaming or re-noting a schedule will not re-send them.
</p> </p>
{% endif %} {% endif %}
</div> </div>
@@ -124,6 +220,46 @@
</div> </div>
<script> <script>
// Recurrence blocks follow the chosen frequency (phase46/47).
// Display only — the server re-validates and clears the unused blocks on save.
(function () {
var freq = document.getElementById('frequencySelect');
var weekly = document.getElementById('weeklyBlock');
var monthly = document.getElementById('monthlyBlock');
var monthMode = document.getElementById('monthModeSelect');
var domField = document.getElementById('dayOfMonthField');
var nthField = document.getElementById('nthWeekdayField');
var endBlock = document.getElementById('endDateBlock');
var dueLabel = document.getElementById('dueDateLabel');
var isEdit = {{ 'true' if schedule else 'false' }};
var MONTHLY = ['monthly', 'quarterly', 'bi-annually', 'annually'];
function syncMonthMode() {
if (!monthMode || !domField || !nthField) return;
var nth = monthMode.value === 'nth_weekday';
domField.style.display = nth ? 'none' : '';
nthField.style.display = nth ? '' : 'none';
}
function sync() {
if (!freq) return;
var v = freq.value;
if (weekly) weekly.style.display = (v === 'weekly') ? '' : 'none';
if (monthly) monthly.style.display = (MONTHLY.indexOf(v) !== -1) ? '' : 'none';
// A one-time schedule has no end date — it closes when it is completed.
if (endBlock) endBlock.style.display = (v === 'once') ? 'none' : '';
if (dueLabel) {
dueLabel.textContent = isEdit ? 'Next Due Date'
: (v === 'once' ? 'Date' : 'Start Date');
}
syncMonthMode();
}
if (freq) freq.addEventListener('change', sync);
if (monthMode) monthMode.addEventListener('change', syncMonthMode);
sync();
})();
// Facility → Area cascade, reusing the existing inspections AJAX endpoint. // Facility → Area cascade, reusing the existing inspections AJAX endpoint.
(function () { (function () {
var facilitySelect = document.getElementById('facilitySelect'); var facilitySelect = document.getElementById('facilitySelect');
+10 -2
View File
@@ -32,7 +32,7 @@
<tr> <tr>
<th>Name</th><th>Template</th><th>Facility / Area</th> <th>Name</th><th>Template</th><th>Facility / Area</th>
<th>Inspector</th><th>Frequency</th><th>Mode</th><th>Next Due</th> <th>Inspector</th><th>Frequency</th><th>Mode</th><th>Next Due</th>
<th>Last Run</th><th>Status</th><th width="190"></th> <th>Ends</th><th>Last Run</th><th>Status</th><th width="190"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -45,7 +45,7 @@
{% if s.area %}<span class="text-muted small">/ {{ s.area.name }}</span>{% endif %} {% if s.area %}<span class="text-muted small">/ {{ s.area.name }}</span>{% endif %}
</td> </td>
<td>{{ s.inspector.display_name if s.inspector else '—' }}</td> <td>{{ s.inspector.display_name if s.inspector else '—' }}</td>
<td><span class="badge bg-secondary">{{ s.frequency|title }}</span></td> <td><span class="badge bg-secondary">{{ s.recurrence_label }}</span></td>
<td> <td>
{% if s.mode == 'plan' %} {% if s.mode == 'plan' %}
<span class="badge bg-info text-dark" title="Inspector presses Start">Plan</span> <span class="badge bg-info text-dark" title="Inspector presses Start">Plan</span>
@@ -59,11 +59,19 @@
<span class="badge bg-danger ms-1">Overdue</span> <span class="badge bg-danger ms-1">Overdue</span>
{% endif %} {% endif %}
</td> </td>
<td class="small text-muted">
{% if s.frequency == 'once' %}—
{% elif s.end_date %}{{ s.end_date.strftime('%Y-%m-%d') }}
{% else %}No end{% endif %}
</td>
<td class="small text-muted"> <td class="small text-muted">
{{ s.last_run_at.strftime('%Y-%m-%d %H:%M') if s.last_run_at else 'Never' }} {{ s.last_run_at.strftime('%Y-%m-%d %H:%M') if s.last_run_at else 'Never' }}
</td> </td>
<td> <td>
{# "Ended" separates a schedule that reached its end date from one a
manager switched off — both are inactive, for different reasons. #}
{% if s.active %}<span class="badge bg-success">Active</span> {% if s.active %}<span class="badge bg-success">Active</span>
{% elif s.is_expired %}<span class="badge bg-dark">Ended</span>
{% else %}<span class="badge bg-secondary">Paused</span>{% endif %} {% else %}<span class="badge bg-secondary">Paused</span>{% endif %}
</td> </td>
<td class="text-end"> <td class="text-end">
@@ -0,0 +1,115 @@
"""phase45 — widen inspection_schedules.frequency ENUM
Converges MT's cadence set onto the agreed union:
once | daily | weekly | monthly | quarterly | bi-annually | annually
'bi-annually' means every 6 months (not twice a year).
Why now
-------
phase46 (recurrence) and phase47 (end date) both branch on 'once':
* a one-time schedule deactivates on completion instead of rolling forward;
* a one-time schedule may not carry an end date it ends by being completed.
Adding the value later would mean a second ENUM rewrite of the same table.
'bi-annually' and 'annually' are added in the same pass for the same reason.
The original four values are retained in their original order, so NO existing
row changes and no data migration is needed. This is purely a widening.
MySQL ENUM changes
------------------
`MODIFY COLUMN` on an ENUM cannot be done in place MySQL rebuilds the table.
`ALGORITHM=COPY, LOCK=SHARED` is stated explicitly rather than left to the
server's default so the behaviour is predictable across versions: the table is
readable throughout and blocks writes for the duration of the copy. It also
takes a metadata lock, which will WAIT behind any long-running transaction
touching inspection_schedules see the deploy notes. The table is small (one
row per schedule), so the copy itself is fast.
No batch_alter_table (MySQL).
RE-RUNNABLE. An existence check is not enough here the column already exists;
what changes is its type. This inspects COLUMN_TYPE and skips when the target
values are already present, so a re-run against a fully-migrated schema is a
genuine no-op rather than a needless table rebuild.
"""
revision = 'phase45_schedule_frequency_enum'
down_revision = 'phase44_internal_handler'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
_TABLE = 'inspection_schedules'
_COLUMN = 'frequency'
_NEW_ENUM = ("ENUM('once','daily','weekly','monthly','quarterly',"
"'bi-annually','annually')")
_OLD_ENUM = "ENUM('daily','weekly','monthly','quarterly')"
def _table_exists(conn, table):
return conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t"
), {"t": table}).scalar() > 0
def _column_type(conn, table, column):
"""Lowercased COLUMN_TYPE, or None when the column does not exist."""
row = conn.execute(sa.text(
"SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() "
"AND TABLE_NAME = :t AND COLUMN_NAME = :c"
), {"t": table, "c": column}).first()
if not row:
return None
value = row[0]
if isinstance(value, bytes):
value = value.decode('utf-8', 'replace')
return value.lower()
def upgrade():
bind = op.get_bind()
if not _table_exists(bind, _TABLE):
return
col_type = _column_type(bind, _TABLE, _COLUMN)
if col_type is None:
return
# Already widened? Every new value present means there is nothing to do.
if all(v in col_type for v in ("'once'", "'bi-annually'", "'annually'")):
return
op.execute(sa.text(
f"ALTER TABLE {_TABLE} MODIFY COLUMN {_COLUMN} {_NEW_ENUM} "
f"NOT NULL DEFAULT 'weekly', ALGORITHM=COPY, LOCK=SHARED"
))
def downgrade():
bind = op.get_bind()
if not _table_exists(bind, _TABLE):
return
col_type = _column_type(bind, _TABLE, _COLUMN)
if col_type is None or "'once'" not in col_type:
return
# Rows holding a value the narrow ENUM cannot represent would be silently
# truncated to '' by the MODIFY. Map them to the nearest surviving cadence
# first so the downgrade is lossy in a defined, inspectable way rather than
# producing invalid empty-string rows.
op.execute(sa.text(
f"UPDATE {_TABLE} SET {_COLUMN} = 'monthly' "
f"WHERE {_COLUMN} IN ('once','bi-annually','annually')"
))
op.execute(sa.text(
f"ALTER TABLE {_TABLE} MODIFY COLUMN {_COLUMN} {_OLD_ENUM} "
f"NOT NULL DEFAULT 'weekly', ALGORITHM=COPY, LOCK=SHARED"
))
@@ -0,0 +1,84 @@
"""phase46 — inspection schedule day-of-week / day-of-month recurrence
Ports single-tenant phase43 onto MT's `inspection_schedules` table (MT's name
for the same thing no rename, rule 7). Adds the recurrence-detail columns so a
weekly schedule can name the weekdays it runs on (Mon/Wed/Fri) and a monthly (or
quarterly / bi-annual / annual) 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 phase34/phase43 rows keep NULLs and fall
back to the plain-interval behaviour in InspectionSchedule._add_interval(), so
no schedule changes cadence on deploy.
`next_run_at` is NOT touched it keeps its name, its DATETIME type and its
index. It remains the due datetime (ST's `next_due_date` by another name); the
recurrence maths runs on its DATE part and preserves its TIME part.
`month_mode` is VARCHAR rather than ENUM so adding a recurrence style later
needs no 3-step MySQL ENUM migration.
Uses INFORMATION_SCHEMA column-existence checks safe to re-run on every
tenant DB. Additive only: nothing is renamed, retyped or dropped.
"""
revision = 'phase46_schedule_recurrence'
down_revision = 'phase45_schedule_frequency_enum'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
_TABLE = 'inspection_schedules'
_COLUMNS = (
('weekdays', 'VARCHAR(20) NULL'),
('month_mode', 'VARCHAR(20) NULL'),
('day_of_month', 'SMALLINT NULL'),
('nth_week', 'SMALLINT NULL'),
('nth_weekday', 'SMALLINT NULL'),
)
def _table_exists(conn, table):
return conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t"
), {"t": table}).scalar() > 0
def _column_exists(conn, table, column):
return conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() "
"AND TABLE_NAME = :t AND COLUMN_NAME = :c"
), {"t": table, "c": column}).scalar() > 0
def upgrade():
bind = op.get_bind()
if not _table_exists(bind, _TABLE):
return
for name, ddl in _COLUMNS:
if not _column_exists(bind, _TABLE, name):
op.execute(sa.text(
f"ALTER TABLE {_TABLE} ADD COLUMN {name} {ddl}"
))
def downgrade():
bind = op.get_bind()
if not _table_exists(bind, _TABLE):
return
for name, _ddl in reversed(_COLUMNS):
if _column_exists(bind, _TABLE, name):
op.execute(sa.text(
f"ALTER TABLE {_TABLE} DROP COLUMN {name}"
))
@@ -0,0 +1,73 @@
"""phase47 — inspection schedule end date
Ports single-tenant phase44 onto MT's `inspection_schedules` table. Adds:
end_date DATE NULL -- last date this schedule may produce an occurrence
Separates the two ideas that `next_run_at` was carrying at once. `next_run_at`
is *mutable state* InspectionSchedule.advance_due_date() rewrites it after
every occurrence, whether the inspector completed a plan-mode schedule or the
cron materialised an auto-mode one whereas `end_date` is a *fixed boundary*
set by the manager and never touched by the app. NULL means "repeat
indefinitely", which is the behaviour every existing row has today, so there is
no backfill and no schedule changes cadence on deploy.
Applies to BOTH modes. Scoping it to plan mode would leave an auto schedule
materialising inspections past its boundary forever, which is the exact failure
the column exists to prevent.
Only meaningful for recurring schedules; the create/edit routes force it to NULL
when frequency == 'once' (a one-time schedule already ends by deactivating on
completion) and reject an end date submitted against one.
Uses an INFORMATION_SCHEMA column-existence check safe to re-run on every
tenant DB. Additive only: no existing column is renamed, retyped or dropped. In
particular `next_run_at` keeps its name and its index it is the API payload
key (`next_due_date`) the iPad decodes and drives the cron.
"""
revision = 'phase47_schedule_end_date'
down_revision = 'phase46_schedule_recurrence'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
_TABLE = 'inspection_schedules'
def _table_exists(conn, table):
return conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t"
), {"t": table}).scalar() > 0
def _column_exists(conn, table, column):
return conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() "
"AND TABLE_NAME = :t AND COLUMN_NAME = :c"
), {"t": table, "c": column}).scalar() > 0
def upgrade():
bind = op.get_bind()
if not _table_exists(bind, _TABLE):
return
if not _column_exists(bind, _TABLE, 'end_date'):
op.execute(sa.text(
f"ALTER TABLE {_TABLE} ADD COLUMN end_date DATE NULL AFTER next_run_at"
))
def downgrade():
bind = op.get_bind()
if not _table_exists(bind, _TABLE):
return
if _column_exists(bind, _TABLE, 'end_date'):
op.execute(sa.text(
f"ALTER TABLE {_TABLE} DROP COLUMN end_date"
))
+473
View File
@@ -0,0 +1,473 @@
"""
tests/test_schedule_recurrence.py
----------------------------------
Behaviour tests for phase45 (frequency ENUM), phase46 (day-of-week /
day-of-month recurrence) and phase47 (end date) on `inspection_schedules`.
Runs on the in-memory SQLite app fixture (multi-tenancy inert). Covers:
* pure date maths weekly weekday sets, monthly day-of-month with short-month
clamping, monthly nth-weekday, the longer month-stepping cadences
* align_due_date() snapping a picked start date forward onto the rule
* fulfill() rolling forward on the rule, deactivating a 'once' schedule, and
deactivating when the next occurrence crosses the end date
* fulfill() with NO next_run_fn argument still advances the due date the
pre-phase46 trap where omitting it left the schedule perpetually due
* legacy rows (all recurrence columns NULL) keeping their exact old cadence
* the cron endpoint's expiry sweep deactivating a schedule that passed its end
date without ever running, and reporting it as "expired"
"""
from datetime import date, datetime, timedelta
import pytest
@pytest.fixture
def client(app):
"""Fresh schema + test client for each test (isolated in-memory DB)."""
app.config['DIGEST_SECRET'] = 'test-digest'
with app.app_context():
from app import db
db.drop_all()
db.create_all()
yield app.test_client()
db.session.remove()
def _seed(suffix='a'):
from app import db
from app.models.user import User
from app.models.facility import Facility
from app.models.inspection import InspectionTemplate
inspector = User(username=f'insp_{suffix}', full_name='Ivy Inspector',
email=f'insp_{suffix}@example.com', role='inspector')
inspector.set_password('x')
tmpl = InspectionTemplate(name='Restroom Check', active=True,
form_schema=[{'id': 'f1', 'type': 'rating_5', 'label': 'Clean'}])
fac = Facility(name='Main Office', active=True)
db.session.add_all([inspector, tmpl, fac])
db.session.commit()
return inspector, tmpl, fac
def _sched(suffix, **kw):
"""Persist an InspectionSchedule with sensible defaults."""
from app import db
from app.models.inspection_schedule import InspectionSchedule
inspector, tmpl, fac = _seed(suffix)
kw.setdefault('name', 'Test schedule')
kw.setdefault('mode', 'plan')
kw.setdefault('active', True)
s = InspectionSchedule(template_id=tmpl.id, facility_id=fac.id,
inspector_id=inspector.id, **kw)
db.session.add(s)
db.session.commit()
return s
# ── Pure date maths (phase46) ────────────────────────────────────────────────
def test_weekly_weekday_set_advances_within_the_week(client):
# Mon=0, Wed=2, Fri=4
s = _sched('wk', frequency='weekly')
s.set_weekdays([0, 2, 4])
assert s.weekday_list == [0, 2, 4]
assert s.weekdays == '0,2,4'
monday = date(2026, 8, 3) # a Monday
assert monday.weekday() == 0
assert s.next_occurrence_after(monday) == date(2026, 8, 5) # Wed
assert s.next_occurrence_after(date(2026, 8, 5)) == date(2026, 8, 7) # Fri
# Friday wraps to the following Monday, not +7 days.
assert s.next_occurrence_after(date(2026, 8, 7)) == date(2026, 8, 10)
def test_align_due_date_snaps_forward_onto_the_rule(client):
s = _sched('align', frequency='weekly')
s.set_weekdays([0, 2, 4])
tuesday = date(2026, 8, 4)
assert tuesday.weekday() == 1
# Picking a Tuesday on a Mon/Wed/Fri schedule yields that Wednesday.
assert s.align_due_date(tuesday) == date(2026, 8, 5)
# A date already on the rule is left alone.
assert s.align_due_date(date(2026, 8, 5)) == date(2026, 8, 5)
def test_monthly_day_of_month_clamps_to_short_months(client):
from app.models.inspection_schedule import MONTH_MODE_DAY
s = _sched('dom', frequency='monthly')
s.month_mode = MONTH_MODE_DAY
s.day_of_month = 31
assert s.next_occurrence_after(date(2026, 1, 31)) == date(2026, 2, 28)
assert s.next_occurrence_after(date(2026, 3, 31)) == date(2026, 4, 30)
def test_monthly_nth_weekday(client):
from app.models.inspection_schedule import MONTH_MODE_NTH
s = _sched('nth', frequency='monthly')
s.month_mode = MONTH_MODE_NTH
s.nth_week = 2
s.nth_weekday = 1 # Tuesday
nxt = s.next_occurrence_after(date(2026, 8, 20))
assert nxt == date(2026, 9, 8)
assert nxt.weekday() == 1
s.nth_week = -1 # last Tuesday
assert s.next_occurrence_after(date(2026, 8, 20)) == date(2026, 9, 29)
def test_longer_cadences_step_whole_months(client):
s = _sched('long', frequency='quarterly')
assert s.next_occurrence_after(date(2026, 1, 15)) == date(2026, 4, 15)
s.frequency = 'bi-annually'
assert s.next_occurrence_after(date(2026, 1, 15)) == date(2026, 7, 15)
s.frequency = 'annually'
assert s.next_occurrence_after(date(2026, 1, 15)) == date(2027, 1, 15)
def test_legacy_row_with_null_recurrence_keeps_old_cadence(client):
"""A pre-phase46 row has every recurrence column NULL and must not change."""
s = _sched('legacy', frequency='weekly')
assert s.weekdays is None and s.month_mode is None
assert s.next_occurrence_after(date(2026, 8, 4)) == date(2026, 8, 11)
s.frequency = 'monthly'
assert s.next_occurrence_after(date(2026, 8, 4)) == date(2026, 9, 4)
# ── fulfill() (phase46/47) ───────────────────────────────────────────────────
def test_fulfill_without_next_run_fn_still_advances(client):
"""The pre-phase46 trap: omitting next_run_fn used to leave next_run_at
untouched, leaving the schedule perpetually due."""
from app import db
from app.utils.time_utils import now_eastern
due = now_eastern().replace(hour=6, minute=0, second=0, microsecond=0)
s = _sched('nofn', frequency='daily', next_run_at=due)
before = s.next_run_at
s.fulfill() # NO next_run_fn
db.session.commit()
assert s.next_run_at > before
assert s.next_run_at.date() > now_eastern().date()
assert s.active is True
assert s.last_completed_at is not None
# Time-of-day preserved.
assert s.next_run_at.hour == 6
def test_fulfill_rolls_forward_on_the_weekday_rule(client):
from app import db
from app.utils.time_utils import now_eastern
today = now_eastern().date()
s = _sched('roll', frequency='weekly',
next_run_at=datetime.combine(today, datetime.min.time()).replace(hour=6))
# Every weekday, so the next occurrence is simply tomorrow.
s.set_weekdays([0, 1, 2, 3, 4, 5, 6])
db.session.commit()
s.fulfill()
db.session.commit()
assert s.next_run_at.date() == today + timedelta(days=1)
assert s.active is True
def test_fulfill_deactivates_a_once_schedule(client):
from app import db
from app.utils.time_utils import now_eastern
due = now_eastern().replace(hour=6, minute=0, second=0, microsecond=0)
s = _sched('once', frequency='once', next_run_at=due)
s.fulfill()
db.session.commit()
assert s.active is False
# The due date is left where it was — the row still shows the occurrence.
assert s.next_run_at == due
def test_fulfill_deactivates_when_next_occurrence_passes_end_date(client):
from app import db
from app.utils.time_utils import now_eastern
today = now_eastern().date()
due = datetime.combine(today, datetime.min.time()).replace(hour=6)
# Daily schedule whose end date is today: tomorrow's occurrence is past it.
s = _sched('end', frequency='daily', next_run_at=due, end_date=today)
assert s.is_within_end_date(today) is True
assert s.is_within_end_date(today + timedelta(days=1)) is False
s.fulfill()
db.session.commit()
assert s.active is False
assert s.next_run_at.date() == today + timedelta(days=1)
def test_no_end_date_repeats_indefinitely(client):
from app import db
from app.utils.time_utils import now_eastern
due = now_eastern().replace(hour=6, minute=0, second=0, microsecond=0)
s = _sched('noend', frequency='daily', next_run_at=due)
assert s.end_date is None
assert s.is_expired is False
for _ in range(5):
s.fulfill()
db.session.commit()
assert s.active is True
# ── Expiry sweep (phase47) ───────────────────────────────────────────────────
def test_expire_if_past_end_date_is_idempotent(client):
from app.utils.time_utils import now_eastern
today = now_eastern().date()
s = _sched('exp', frequency='daily', end_date=today - timedelta(days=1))
assert s.is_expired is True
assert s.expire_if_past_end_date(today) is True
assert s.active is False
# Second call changes nothing.
assert s.expire_if_past_end_date(today) is False
def test_cron_expires_a_schedule_that_never_ran(client):
"""A schedule can reach its end date without ever producing an occurrence,
so fulfill() never runs and the boundary is checked nowhere else."""
from app import db
from app.models.inspection_schedule import InspectionSchedule
from app.utils.time_utils import now_eastern
today = now_eastern().date()
s = _sched('cron', frequency='daily', mode='plan',
next_run_at=datetime.combine(today - timedelta(days=10),
datetime.min.time()).replace(hour=6),
end_date=today - timedelta(days=5))
sid = s.id
resp = client.post('/inspection-schedules/run', data={'token': 'test-digest'})
assert resp.status_code == 200
payload = resp.get_json()
assert payload['ok'] is True
assert payload['expired'] == 1
db.session.expire_all()
reloaded = db.session.get(InspectionSchedule, sid)
assert reloaded.active is False
# And it must not have generated an overdue alert on the way out.
assert reloaded.overdue_notified is False
def test_cron_leaves_a_live_schedule_alone(client):
from app import db
from app.models.inspection_schedule import InspectionSchedule
from app.utils.time_utils import now_eastern
today = now_eastern().date()
s = _sched('live', frequency='daily', mode='plan',
next_run_at=datetime.combine(today + timedelta(days=3),
datetime.min.time()).replace(hour=6),
end_date=today + timedelta(days=30))
sid = s.id
resp = client.post('/inspection-schedules/run', data={'token': 'test-digest'})
assert resp.get_json()['expired'] == 0
db.session.expire_all()
assert db.session.get(InspectionSchedule, sid).active is True
# ── Routes: create / edit form handling ──────────────────────────────────────
def _seed_manager(username='mgr'):
from app import db
from app.models.user import User
u = User(username=username, full_name='Mo Manager',
email=f'{username}@example.com', role='admin', active=True)
u.set_password('pw-correct1')
db.session.add(u)
db.session.commit()
return u
def _login(client, username='mgr'):
return client.post('/auth/login',
data={'username': username, 'password': 'pw-correct1'},
follow_redirects=True)
def test_create_route_stores_recurrence_and_end_date(client):
from app import db
from app.models.inspection_schedule import InspectionSchedule
inspector, tmpl, fac = _seed('crt')
_seed_manager()
_login(client)
start = date(2026, 8, 4) # a Tuesday
end = date(2026, 12, 31)
resp = client.post('/inspection-schedules/new', data={
'name': 'MWF restrooms', 'template_id': tmpl.id, 'facility_id': fac.id,
'inspector_id': inspector.id, 'frequency': 'weekly', 'mode': 'plan',
'weekdays': ['0', '2', '4'],
'next_due_date': start.isoformat(), 'end_date': end.isoformat(),
}, follow_redirects=True)
assert resp.status_code == 200
s = InspectionSchedule.query.filter_by(name='MWF restrooms').one()
assert s.weekday_list == [0, 2, 4]
assert s.end_date == end
# Tuesday snapped forward onto the Wednesday.
assert s.due_date == date(2026, 8, 5)
assert s.recurrence_label == 'Weekly · Mon, Wed, Fri'
def test_create_rejects_weekly_with_no_weekdays(client):
from app.models.inspection_schedule import InspectionSchedule
inspector, tmpl, fac = _seed('nowd')
_seed_manager()
_login(client)
client.post('/inspection-schedules/new', data={
'name': 'No days', 'template_id': tmpl.id, 'facility_id': fac.id,
'inspector_id': inspector.id, 'frequency': 'weekly', 'mode': 'plan',
}, follow_redirects=True)
assert InspectionSchedule.query.filter_by(name='No days').first() is None
def test_create_rejects_end_date_on_a_one_time_schedule(client):
from app.models.inspection_schedule import InspectionSchedule
inspector, tmpl, fac = _seed('onceend')
_seed_manager()
_login(client)
client.post('/inspection-schedules/new', data={
'name': 'Once with end', 'template_id': tmpl.id, 'facility_id': fac.id,
'inspector_id': inspector.id, 'frequency': 'once', 'mode': 'plan',
'next_due_date': '2026-09-01', 'end_date': '2026-10-01',
}, follow_redirects=True)
assert InspectionSchedule.query.filter_by(name='Once with end').first() is None
def test_create_rejects_first_occurrence_past_the_end_date(client):
"""The end date clears the *picked* date but not the *aligned* one: a
Mon/Wed/Fri schedule started on a Tuesday first runs on the Wednesday."""
from app.models.inspection_schedule import InspectionSchedule
inspector, tmpl, fac = _seed('past')
_seed_manager()
_login(client)
client.post('/inspection-schedules/new', data={
'name': 'Impossible', 'template_id': tmpl.id, 'facility_id': fac.id,
'inspector_id': inspector.id, 'frequency': 'weekly', 'mode': 'plan',
'weekdays': ['0', '2', '4'],
'next_due_date': '2026-08-04', # Tuesday -> aligns to Wed the 5th
'end_date': '2026-08-04', # ...which is past this
}, follow_redirects=True)
assert InspectionSchedule.query.filter_by(name='Impossible').first() is None
def test_edit_does_not_move_the_due_date_on_an_unrelated_change(client):
"""Root-cause regression: edit() used to recompute next_run_at from now on
every save, discarding the manager's chosen due date and re-arming every
reminder."""
from app import db
from app.models.inspection_schedule import InspectionSchedule
inspector, tmpl, fac = _seed('edit')
_seed_manager()
_login(client)
s = InspectionSchedule(
name='Original', template_id=tmpl.id, facility_id=fac.id,
inspector_id=inspector.id, frequency='weekly', mode='plan', active=True,
next_run_at=datetime(2026, 9, 2, 6, 0), weekdays='0,2,4',
advance_notified=True, due_notified=True,
)
db.session.add(s)
db.session.commit()
sid, original_due = s.id, s.next_run_at
# Rename only — resubmit the same recurrence and due date.
client.post(f'/inspection-schedules/{sid}/edit', data={
'name': 'Renamed', 'template_id': tmpl.id, 'facility_id': fac.id,
'inspector_id': inspector.id, 'frequency': 'weekly', 'mode': 'plan',
'weekdays': ['0', '2', '4'],
'next_due_date': original_due.date().isoformat(),
'active': 'on',
}, follow_redirects=True)
db.session.expire_all()
reloaded = db.session.get(InspectionSchedule, sid)
assert reloaded.name == 'Renamed'
assert reloaded.next_run_at == original_due # unchanged
assert reloaded.advance_notified is True # reminders NOT re-armed
assert reloaded.due_notified is True
def test_edit_resets_reminders_when_the_due_date_moves(client):
from app import db
from app.models.inspection_schedule import InspectionSchedule
inspector, tmpl, fac = _seed('move')
_seed_manager()
_login(client)
s = InspectionSchedule(
name='Movable', template_id=tmpl.id, facility_id=fac.id,
inspector_id=inspector.id, frequency='daily', mode='plan', active=True,
next_run_at=datetime(2026, 9, 2, 6, 0),
advance_notified=True, due_notified=True, overdue_notified=True,
)
db.session.add(s)
db.session.commit()
sid = s.id
client.post(f'/inspection-schedules/{sid}/edit', data={
'name': 'Movable', 'template_id': tmpl.id, 'facility_id': fac.id,
'inspector_id': inspector.id, 'frequency': 'daily', 'mode': 'plan',
'next_due_date': '2026-09-20', 'active': 'on',
}, follow_redirects=True)
db.session.expire_all()
reloaded = db.session.get(InspectionSchedule, sid)
assert reloaded.next_run_at.date() == date(2026, 9, 20)
assert reloaded.next_run_at.hour == 6 # time-of-day preserved
assert reloaded.advance_notified is False
assert reloaded.due_notified is False
assert reloaded.overdue_notified is False
# ── Labels (phase45/46) ──────────────────────────────────────────────────────
def test_recurrence_label(client):
from app.models.inspection_schedule import MONTH_MODE_DAY, MONTH_MODE_NTH
s = _sched('lbl', frequency='weekly')
s.set_weekdays([0, 2, 4])
assert s.recurrence_label == 'Weekly · Mon, Wed, Fri'
s.frequency = 'monthly'
s.weekdays = None
s.month_mode = MONTH_MODE_DAY
s.day_of_month = 15
assert s.recurrence_label == 'Monthly · day 15'
s.month_mode = MONTH_MODE_NTH
s.day_of_month = None
s.nth_week = 2
s.nth_weekday = 1
assert s.recurrence_label == 'Monthly · 2nd Tuesday'
s.frequency = 'bi-annually'
assert s.recurrence_label.startswith('Every 6 months')
s.frequency = 'once'
assert s.recurrence_label == 'One-time'