Jul 27 - Update code for scheduled tasks 2

This commit is contained in:
Nguyen Ngo
2026-07-27 15:26:06 -04:00
parent 44b577b59f
commit 455534ccda
9 changed files with 306 additions and 5 deletions
+3
View File
@@ -51,6 +51,9 @@ def _scheduled_payload(s):
'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,
# phase44. 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(),
'notes': s.notes or None,
}
+56 -1
View File
@@ -17,6 +17,12 @@ POST /scheduled-inspections/run?token=DIGEST_SECRET:
- overdue alert to admin/director once the due date passes uncompleted
The *_notified flags make each of those fire at most once per occurrence and
reset when a recurring schedule rolls forward.
Two dates, deliberately distinct (phase44):
next_due_date — 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 = forever.
"""
import calendar
@@ -85,6 +91,11 @@ class ScheduledInspection(db.Model):
nullable=True, index=True)
frequency = db.Column(db.Enum(*FREQUENCY_CHOICES), nullable=False, default='once')
next_due_date = db.Column(db.Date, nullable=False, index=True)
# Fixed boundary set by the manager, never rewritten by the app — unlike
# next_due_date, which fulfill() advances after every completed inspection.
# NULL = repeat indefinitely. Only meaningful for recurring schedules; the
# create/edit routes force it to NULL when frequency == 'once'.
end_date = db.Column(db.Date, nullable=True)
active = db.Column(db.Boolean, nullable=False, default=True)
notes = db.Column(db.Text, nullable=True)
@@ -227,10 +238,48 @@ class ScheduledInspection(db.Model):
today = today or now_eastern().date()
return self.active and self.next_due_date < today
# ── End-date boundary (phase44) ──────────────────────────────────────────
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 switched it off, and the list view distinguishes
the two. Compare against the *end date* rather than `next_due_date`,
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 firing overdue alerts forever. Called from run_reminders().
"""
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
def fulfill(self):
"""Mark this occurrence complete. One-time schedules deactivate;
recurring ones roll their due date forward past today and reset the
reminder flags. Caller commits."""
reminder flags. A recurring schedule whose next occurrence would fall
past its end date deactivates instead. Caller commits."""
self.last_completed_at = now_eastern()
if self.frequency == 'once':
self.active = False
@@ -243,6 +292,12 @@ class ScheduledInspection(db.Model):
nxt = self.next_occurrence_after(nxt)
guard += 1
self.next_due_date = nxt
# Past the manager's boundary: this was the last occurrence. next_due_date
# 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
self.advance_notified = False
self.due_notified = False
self.overdue_notified = False
+60 -3
View File
@@ -106,10 +106,32 @@ def _apply_recurrence(sched, form):
sched.month_mode = sched.day_of_month = None
sched.nth_week = sched.nth_weekday = None
# End date (phase44) — a boundary, not a cadence setting. A one-time
# schedule has none: it ends by deactivating when it is completed.
sched.end_date = form.end_date.data if sched.frequency != 'once' else 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 _reject_if_past_end_date(sched, form):
"""True (and a form error set) if the aligned first occurrence falls past
the end date.
The form already rejects an end date earlier than the *picked* due date, but
align_due_date() can push that date forward onto the recurrence rule — pick
a Tuesday for a Mon/Wed/Fri schedule and the first occurrence is Wednesday.
Without this check that combination would save as active with no occurrence
it is ever allowed to run.
"""
if sched.is_within_end_date(sched.next_due_date):
return False
form.end_date.errors.append(
f'With this recurrence the first occurrence falls on '
f'{sched.next_due_date:%b %d, %Y}, after the end date.')
return True
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."""
@@ -167,6 +189,10 @@ def index():
def create():
form = ScheduledInspectionForm()
_populate_choices(form)
# On a new schedule this date IS the start; on edit it is whatever the next
# occurrence happens to be. One field, two meanings — so the label follows
# the context instead of saying both at once.
form.next_due_date.label.text = 'Start Date'
if not form.next_due_date.data:
form.next_due_date.data = now_eastern().date()
@@ -181,11 +207,18 @@ def create():
created_by = current_user.id,
)
_apply_recurrence(sched, form)
if _reject_if_past_end_date(sched, form):
# sched was never added to the session — nothing to roll back.
return render_template('scheduled_inspections/form.html',
form=form, title='New Scheduled Inspection',
projects=_active_contracts(),
selected_project_id=_selected_project_id(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.recurrence_label}; due={sched.next_due_date}; '
f'end={sched.end_date or ""}; '
f'inspector={sched.inspector_id}')
logger.info('SCHED INSP | create | by=%s | id=%s', current_user.username, sched.id)
@@ -213,6 +246,7 @@ def edit(schedule_id):
abort(404)
form = ScheduledInspectionForm(obj=sched)
_populate_choices(form)
form.next_due_date.label.text = 'Next Due Date'
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.
@@ -227,10 +261,20 @@ def edit(schedule_id):
sched.notes = (form.notes.data or '').strip() or None
sched.active = form.active.data
_apply_recurrence(sched, form)
if _reject_if_past_end_date(sched, form):
# sched is a persistent object and has already been mutated — discard
# those pending changes before re-rendering so nothing leaks out on
# the next flush.
db.session.rollback()
return render_template('scheduled_inspections/form.html',
form=form, title='Edit Scheduled Inspection',
schedule=sched, projects=_active_contracts(),
selected_project_id=_selected_project_id(form))
db.session.commit()
log_action(ACTION_UPDATE, 'ScheduledInspection', sched.id,
f'{sched.template.name} @ {sched.facility.name}',
f'freq={sched.recurrence_label}; due={sched.next_due_date}; '
f'end={sched.end_date or ""}; '
f'active={sched.active}')
# Notify the inspector if the assignment changed to them.
@@ -333,10 +377,23 @@ def run_reminders():
abort(403)
today = now_eastern().date()
sent = {'advance': 0, 'due': 0, 'overdue': 0}
sent = {'advance': 0, 'due': 0, 'overdue': 0, 'expired': 0}
schedules = ScheduledInspection.query.filter_by(active=True).all()
# Expire schedules past their end date BEFORE any reminder work (phase44).
# fulfill() closes out a schedule that reaches its boundary by being
# completed; this covers the one that reaches it without ever being done —
# otherwise it stays active and re-alerts as overdue indefinitely.
live = []
for s in schedules:
if s.expire_if_past_end_date(today):
sent['expired'] += 1
logger.info('SCHED INSP | expired | id=%s | end=%s', s.id, s.end_date)
else:
live.append(s)
schedules = live
# Cache admin/director recipients for overdue alerts
managers = User.query.filter(
User.role.in_(['admin', 'director']), User.active == True # noqa: E712
@@ -395,6 +452,6 @@ def run_reminders():
sent['overdue'] += 1
db.session.commit()
logger.info('SCHED INSP | reminders | advance=%s due=%s overdue=%s',
sent['advance'], sent['due'], sent['overdue'])
logger.info('SCHED INSP | reminders | advance=%s due=%s overdue=%s expired=%s',
sent['advance'], sent['due'], sent['overdue'], sent['expired'])
return {'ok': True, 'sent': sent}, 200
+23 -1
View File
@@ -49,7 +49,26 @@
{{ 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 class="form-text">
Snapped forward to the first matching day.
Advances automatically after each completed inspection.
</div>
</div>
</div>
{# ── End date (phase44) ──
Hidden for one-time schedules, which end by deactivating when
completed. syncFrequency() toggles it; the route forces the column
to NULL when frequency == 'once', so a stale DOM value cannot
survive a frequency change. #}
<div class="row" id="end_date_row" hidden>
<div class="col-md-6 mb-3">
{{ form.end_date.label(class="form-label fw-semibold") }}
{{ form.end_date(class="form-control", type="date") }}
{% for e in form.end_date.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
<div class="form-text">
Optional. The last date this schedule may run — leave blank to repeat indefinitely.
</div>
</div>
</div>
@@ -201,6 +220,7 @@
document.querySelector('[name="frequency"]');
var weekly = document.getElementById('weekly_block');
var monthly = document.getElementById('monthly_block');
var endRow = document.getElementById('end_date_row');
if (!freq || !weekly || !monthly) { return; }
var domRadio = document.getElementById('month_mode_day');
@@ -217,6 +237,8 @@
function syncFrequency() {
weekly.hidden = freq.value !== 'weekly';
monthly.hidden = freq.value !== 'monthly';
// End date is a recurring-only concept.
if (endRow) { endRow.hidden = freq.value === 'once'; }
syncMonthMode();
}
@@ -31,6 +31,7 @@
<th>Inspector</th>
<th>Frequency</th>
<th>Next Due</th>
<th>Ends</th>
<th>Status</th>
<th class="text-end"></th>
</tr>
@@ -52,9 +53,22 @@
<span class="badge bg-warning text-dark ms-1">Due soon</span>
{% endif %}
</td>
<td class="text-nowrap">
{% if s.frequency == 'once' %}
<span class="text-muted"></span>
{% elif s.end_date %}
{{ s.end_date.strftime('%b %d, %Y') }}
{% else %}
<span class="text-muted" title="Repeats indefinitely">No end</span>
{% endif %}
</td>
<td>
{# Three states, not two: "Ended" distinguishes a schedule that ran
its course from one a manager switched off. #}
{% if s.active %}
<span class="badge bg-success">Active</span>
{% elif s.is_expired %}
<span class="badge bg-dark" title="Passed its end date">Ended</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
+18
View File
@@ -339,6 +339,10 @@ class ScheduledInspectionForm(FlaskForm):
('weekly', 'Weekly'), ('monthly', 'Monthly'),
], validators=[DataRequired()])
next_due_date = DateField('Start / Due Date', validators=[DataRequired()])
# Label is overridden per context in routes/scheduled_inspections.py:
# "Start Date" when creating, "Next Due Date" when editing. The default
# above is only a fallback.
end_date = DateField('End Date', validators=[Optional()])
# UI label only. The field name, the ScheduledInspection.notes attribute and
# the scheduled_inspections.notes column all stay `notes` — renaming any of
# them would break the API payload key the iPad decodes.
@@ -387,6 +391,20 @@ class ScheduledInspectionForm(FlaskForm):
elif not self.day_of_month.data:
self.day_of_month.errors.append('Enter a day of the month (131).')
ok = False
# End date (phase44). Only meaningful for recurring schedules — a
# one-time schedule ends by deactivating when it is completed. Rejecting
# an end date before the due date here is what makes the "already past
# its boundary on save" case unreachable in the routes.
if self.end_date.data:
if self.frequency.data == 'once':
self.end_date.errors.append(
'A one-time schedule has no end date — it closes when completed.')
ok = False
elif self.next_due_date.data and self.end_date.data < self.next_due_date.data:
self.end_date.errors.append(
'End date must be on or after the due date.')
ok = False
return ok