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

This commit is contained in:
2026-07-26 13:47:17 -04:00
parent 0c63c45b21
commit 16858108b9
10 changed files with 488 additions and 23 deletions
+8
View File
@@ -42,6 +42,14 @@ def _scheduled_payload(s):
'inspector_id': s.inspector_id,
'frequency': s.frequency,
'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,
'is_overdue': s.is_overdue(),
'notes': s.notes or None,
+145 -10
View File
@@ -19,13 +19,56 @@ The *_notified flags make each of those fire at most once per occurrence and
reset when a recurring schedule rolls forward.
"""
from datetime import timedelta
import calendar
from datetime import date, timedelta
from app import db
from app.utils.time_utils import now_eastern
FREQUENCY_CHOICES = ('once', 'daily', 'weekly', 'monthly')
# Monthly recurrence styles (phase43). Stored as VARCHAR, not ENUM, so adding a
# style later needs no 3-step MySQL ENUM dance (CLAUDE.md rule 3).
MONTH_MODE_DAY = 'day_of_month' # "the 15th of every month"
MONTH_MODE_NTH = 'nth_weekday' # "the 2nd Tuesday of every month"
# Python weekday numbering: Monday=0 … Sunday=6 (matches date.weekday()).
WEEKDAY_NAMES = ('Monday', 'Tuesday', 'Wednesday', 'Thursday',
'Friday', 'Saturday', 'Sunday')
WEEKDAY_ABBREV = ('Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun')
# nth_week: 14 are literal, 5 means "5th (or last if the month is short)",
# -1 means "last" explicitly.
NTH_WEEK_LABELS = {1: '1st', 2: '2nd', 3: '3rd', 4: '4th', 5: '5th', -1: 'Last'}
def _last_day_of(year, month):
return calendar.monthrange(year, month)[1]
def _shift_month(year, month, n=1):
"""Return (year, month) shifted by *n* months."""
idx = year * 12 + (month - 1) + n
return idx // 12, idx % 12 + 1
def _nth_weekday_of(year, month, weekday, nth):
"""Date of the *nth* *weekday* in a month.
``nth == -1`` means the last one. A requested 5th occurrence that does not
exist falls back to the 4th, so every month yields a valid date.
"""
last = _last_day_of(year, month)
if nth == -1:
d = date(year, month, last)
return d - timedelta(days=(d.weekday() - weekday) % 7)
first = date(year, month, 1)
day = 1 + ((weekday - first.weekday()) % 7) + (nth - 1) * 7
while day > last:
day -= 7
return date(year, month, day)
class ScheduledInspection(db.Model):
__tablename__ = 'scheduled_inspections'
@@ -45,6 +88,18 @@ class ScheduledInspection(db.Model):
active = db.Column(db.Boolean, nullable=False, default=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'),
nullable=True)
created_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
@@ -72,22 +127,102 @@ class ScheduledInspection(db.Model):
def frequency_label(self):
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
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':
return d + timedelta(days=1)
if frequency == 'weekly':
return d + timedelta(weeks=1)
if frequency == 'monthly':
# Add ~1 month by stepping 2831 days to the same day-of-month where possible.
month = d.month + 1
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)
year, month = _shift_month(d.year, d.month, 1)
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 == '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):
today = today or now_eastern().date()
return self.active and self.next_due_date < today
@@ -102,10 +237,10 @@ class ScheduledInspection(db.Model):
return
# Recurring: advance until the next due date is in the future.
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
while nxt <= today and guard < 400:
nxt = self._add_interval(nxt, self.frequency)
nxt = self.next_occurrence_after(nxt)
guard += 1
self.next_due_date = nxt
self.advance_notified = False
+5
View File
@@ -361,8 +361,10 @@ def index():
# ── Scheduled inspections (phase36): upcoming / overdue ──────────────
sched_upcoming = []
sched_overdue_count = 0
sched_open_inspections = {}
if not is_customer:
from app.models.scheduled_inspection import ScheduledInspection
from app.routes.scheduled_inspections import _open_inspection_ids
_today = now.date()
_sq = ScheduledInspection.query.filter_by(active=True)
if is_inspector:
@@ -374,11 +376,14 @@ def index():
s for s in _all_sched
if _today <= s.next_due_date <= _today + timedelta(days=7)
][:8]
# Offer Continue (not a duplicate Start) where one is already underway.
sched_open_inspections = _open_inspection_ids(sched_upcoming)
return render_template(
'dashboard.html',
sched_upcoming = sched_upcoming,
sched_overdue_count = sched_overdue_count,
sched_open_inspections = sched_open_inspections,
submitted_this_week = submitted_this_week,
completed_today = completed_today,
open_issues = open_issues,
+69 -7
View File
@@ -22,7 +22,8 @@ from flask import (Blueprint, render_template, redirect, url_for, flash,
from flask_login import login_required, current_user
from 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.inspection import Inspection, InspectionTemplate
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):
"""Contract of the submitted facility (for restoring the selector on
re-render), or None."""
@@ -110,7 +155,8 @@ def index():
).all()
return render_template('scheduled_inspections/list.html',
schedules=schedules, today=today)
schedules=schedules, today=today,
open_inspections=_open_inspection_ids(schedules))
# ── Create ──────────────────────────────────────────────────────────────────
@@ -129,17 +175,18 @@ def create():
facility_id = form.facility_id.data,
template_id = form.template_id.data,
inspector_id = form.inspector_id.data,
frequency = form.frequency.data,
next_due_date = form.next_due_date.data,
notes = (form.notes.data or '').strip() or None,
active = form.active.data,
created_by = current_user.id,
)
_apply_recurrence(sched, form)
db.session.add(sched)
db.session.commit()
log_action(ACTION_CREATE, 'ScheduledInspection', sched.id,
f'{sched.template.name} @ {sched.facility.name}',
f'freq={sched.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)
# Notify the assigned inspector immediately.
@@ -166,20 +213,25 @@ def edit(schedule_id):
abort(404)
form = ScheduledInspectionForm(obj=sched)
_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():
old_inspector_id = sched.inspector_id
sched.facility_id = form.facility_id.data
sched.template_id = form.template_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.active = form.active.data
_apply_recurrence(sched, form)
db.session.commit()
log_action(ACTION_UPDATE, 'ScheduledInspection', sched.id,
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.
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')
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(
template_id = sched.template_id,
facility_id = sched.facility_id,
+9 -1
View File
@@ -48,7 +48,7 @@
<div class="table-responsive">
<table class="table table-sm table-hover mb-0 align-middle">
<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>
<tbody>
{% for s in sched_upcoming %}
@@ -56,13 +56,21 @@
<td>{{ s.facility.name if s.facility 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 text-muted">{{ s.recurrence_label }}</td>
<td class="small">{{ s.next_due_date.strftime('%b %d') }}</td>
<td class="text-end">
{# Start is shown only to the assignee — the inspection is theirs to do. #}
{% 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) }}"
class="btn btn-sm btn-success py-0"><i class="bi bi-play-fill"></i> Start</a>
{% endif %}
{% endif %}
</td>
</tr>
{% endfor %}
@@ -49,6 +49,64 @@
{{ form.next_due_date.label(class="form-label fw-semibold") }}
{{ form.next_due_date(class="form-control", type="date") }}
{% for e in form.next_due_date.errors %}<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>
@@ -129,5 +187,40 @@
setPlaceholder();
}
}());
// Recurrence blocks: only the one matching the chosen frequency is shown.
// The server clears the columns for the hidden blocks on save, so stale values
// left in the DOM never take effect.
(function () {
'use strict';
var freq = document.getElementById('frequency') ||
document.querySelector('[name="frequency"]');
var weekly = document.getElementById('weekly_block');
var monthly = document.getElementById('monthly_block');
if (!freq || !weekly || !monthly) { return; }
var domRadio = document.getElementById('month_mode_day');
var nthRadio = document.getElementById('month_mode_nth');
var domRow = document.getElementById('dom_row');
var nthRow = document.getElementById('nth_row');
function syncMonthMode() {
var useNth = nthRadio && nthRadio.checked;
domRow.style.opacity = useNth ? '.45' : '1';
nthRow.style.opacity = useNth ? '1' : '.45';
}
function syncFrequency() {
weekly.hidden = freq.value !== 'weekly';
monthly.hidden = freq.value !== 'monthly';
syncMonthMode();
}
freq.addEventListener('change', syncFrequency);
[domRadio, nthRadio].forEach(function (r) {
if (r) { r.addEventListener('change', syncMonthMode); }
});
syncFrequency();
}());
</script>
{% endblock %}
@@ -43,7 +43,7 @@
<td><strong>{{ s.facility.name if s.facility else '—' }}</strong></td>
<td>{{ s.template.name if s.template else '—' }}</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>
{{ s.next_due_date.strftime('%b %d, %Y') }}
{% if overdue %}
@@ -62,11 +62,19 @@
<td class="text-end text-nowrap">
{# Start is shown only to the assignee — the inspection is theirs to do. #}
{% if s.active and s.inspector_id and s.inspector_id == current_user.id %}
{% set open_id = open_inspections.get(s.id) %}
{% if open_id %}
<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) }}"
class="btn btn-sm btn-success" title="Start this inspection">
<i class="bi bi-play-fill"></i> Start
</a>
{% endif %}
{% endif %}
{% if current_user.role in ['admin','director','project_manager','auditor'] %}
<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>
+46 -2
View File
@@ -2,7 +2,7 @@ from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed, MultipleFileField
from wtforms import (StringField, PasswordField, SelectField, TextAreaField,
DecimalField, BooleanField, IntegerField, HiddenField,
RadioField, DateField)
RadioField, DateField, SelectMultipleField)
from wtforms.validators import (DataRequired, Email, Length, EqualTo,
Optional, NumberRange, ValidationError)
from app.models.user import User
@@ -338,10 +338,54 @@ class ScheduledInspectionForm(FlaskForm):
('once', 'One-time'), ('daily', 'Daily'),
('weekly', 'Weekly'), ('monthly', 'Monthly'),
], validators=[DataRequired()])
next_due_date = DateField('Due Date', validators=[DataRequired()])
next_due_date = DateField('Start / Due Date', validators=[DataRequired()])
notes = TextAreaField('Notes', validators=[Optional(), Length(max=1000)])
active = BooleanField('Active', default=True)
# ── Recurrence detail (phase43) ──────────────────────────────────────────
# Only the block matching `frequency` is required; the rest is ignored and
# cleared on save. Shown/hidden client-side, enforced in validate() below.
weekdays = SelectMultipleField(
'Days of the Week', coerce=int, validators=[Optional()],
choices=[(i, n) for i, n in enumerate(
['Monday', 'Tuesday', 'Wednesday', 'Thursday',
'Friday', 'Saturday', 'Sunday'])],
)
month_mode = SelectField('Monthly Rule', validators=[Optional()], choices=[
('day_of_month', 'On a day of the month'),
('nth_weekday', 'On a weekday of the month'),
], default='day_of_month')
day_of_month = IntegerField(
'Day of Month', validators=[Optional(), NumberRange(min=1, max=31)])
nth_week = SelectField('Week', coerce=int, validators=[Optional()], choices=[
(1, '1st'), (2, '2nd'), (3, '3rd'), (4, '4th'), (5, '5th'), (-1, 'Last'),
], default=1)
nth_weekday = SelectField(
'Weekday', coerce=int, validators=[Optional()],
choices=[(i, n) for i, n in enumerate(
['Monday', 'Tuesday', 'Wednesday', 'Thursday',
'Friday', 'Saturday', 'Sunday'])],
default=0,
)
def validate(self, extra_validators=None):
"""Conditionally require the recurrence block for the chosen frequency."""
if not super().validate(extra_validators):
return False
ok = True
if self.frequency.data == 'weekly' and not self.weekdays.data:
self.weekdays.errors.append('Pick at least one day of the week.')
ok = False
elif self.frequency.data == 'monthly':
if self.month_mode.data == 'nth_weekday':
if not self.nth_week.data or self.nth_weekday.data is None:
self.nth_week.errors.append('Choose which weekday of the month.')
ok = False
elif not self.day_of_month.data:
self.day_of_month.errors.append('Enter a day of the month (131).')
ok = False
return ok
# ── Support Knowledge Base (phase38) ─────────────────────────────────────────