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
+8
View File
@@ -448,6 +448,9 @@ All are nullable and **legacy phase36 rows keep NULLs**, falling back to `_add_i
**A plan, not an inspection.** Names a facility + template + assigned inspector + `next_due_date`. Lifecycle: **A plan, not an inspection.** Names a facility + template + assigned inspector + `next_due_date`. Lifecycle:
- The assigned inspector (or a manager) clicks **Start**`scheduled_inspections.start` creates a normal `in_progress` Inspection with `scheduled_inspection_id` set, then redirects to the execute flow. - The assigned inspector (or a manager) clicks **Start**`scheduled_inspections.start` creates a normal `in_progress` Inspection with `scheduled_inspection_id` set, then redirects to the execute flow.
- On **completion** (execute route, status → `completed`), `ScheduledInspection.fulfill()` runs in the same atomic commit: `once``active=False`; recurring → `next_due_date` rolls forward past today via `_add_interval()` and the three `*_notified` flags reset. - On **completion** (execute route, status → `completed`), `ScheduledInspection.fulfill()` runs in the same atomic commit: `once``active=False`; recurring → `next_due_date` rolls forward past today via `_add_interval()` and the three `*_notified` flags reset.
- **End date (phase44).** `end_date` is the manager's boundary; NULL = forever, and it is forced NULL for `once`. Inclusive — an occurrence landing exactly on it still runs. Two enforcement points, both needed: `fulfill()` deactivates when the rolled-forward `next_due_date` passes the boundary (the schedule that ends by being *completed*), and `run_reminders()` calls `expire_if_past_end_date()` on every active schedule before doing any reminder work (the schedule that reaches its boundary *without ever being done* — otherwise it re-alerts as overdue forever). `next_due_date` is left unclamped on expiry so the row shows which occurrence it stopped before.
- **Form validation.** `ScheduledInspectionForm.validate()` rejects an end date on a one-time schedule and one earlier than the due date. That is not sufficient alone: `align_due_date()` can push the picked date forward onto the rule (a Tuesday pick on a Mon/Wed/Fri schedule becomes Wednesday), so `_reject_if_past_end_date()` re-checks after `_apply_recurrence()` in both create and edit. Edit rolls back first — `sched` is persistent and already mutated at that point.
- **Three status states** in the list: Active, **Ended** (`is_expired` — ran its course), Inactive (a manager switched it off).
- **Assignment notification** (immediate): on **create**, the assigned inspector gets an in-app + email "assigned to you" notification; on **edit**, only when the inspector actually changes (a "reassigned to you" notification to the new assignee). Via `_notify_assignee()` in the blueprint using `event_type=EVENT_SCHEDULED_INSPECTION`. - **Assignment notification** (immediate): on **create**, the assigned inspector gets an in-app + email "assigned to you" notification; on **edit**, only when the inspector actually changes (a "reassigned to you" notification to the new assignee). Via `_notify_assignee()` in the blueprint using `event_type=EVENT_SCHEDULED_INSPECTION`.
- **Reminders** are dispatched by the cron endpoint (see §11): advance (1 day before) + due-date to the inspector, overdue to admin/director — each fires at most once per occurrence via the `*_notified` flags. Uses `notify()` with `event_type=EVENT_SCHEDULED_INSPECTION`. - **Reminders** are dispatched by the cron endpoint (see §11): advance (1 day before) + due-date to the inspector, overdue to admin/director — each fires at most once per occurrence via the `*_notified` flags. Uses `notify()` with `event_type=EVENT_SCHEDULED_INSPECTION`.
- Dashboard shows an **upcoming (next 7 days) / overdue** panel for non-customers (inspectors see only their own). - Dashboard shows an **upcoming (next 7 days) / overdue** panel for non-customers (inspectors see only their own).
@@ -863,6 +866,10 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif
→ phase43_sched_recurrence ← HEAD → phase43_sched_recurrence ← HEAD
``` ```
#### phase44 — scheduled inspection end date
Revision id `phase44_sched_end_date` (file `phase44_scheduled_end_date.py`, down_revision `phase43_sched_recurrence`). Adds `scheduled_inspections.end_date DATE NULL` — the last date a recurring schedule may produce an occurrence. **No backfill**: NULL means "repeat indefinitely", which is exactly what every existing row does today, so nothing changes cadence on deploy. Splits the two meanings `next_due_date` was carrying (see §5 `ScheduledInspection` and rule 85). `INFORMATION_SCHEMA` column-existence check — safe to re-run.
### phase43_sched_recurrence ### phase43_sched_recurrence
Revision id `phase43_sched_recurrence`. Adds the five nullable recurrence-detail columns to `scheduled_inspections` (`weekdays`, `month_mode`, `day_of_month`, `nth_week`, `nth_weekday`) so weekly schedules can name their weekdays and monthly schedules can use either a day-of-month or an nth-weekday rule — see §5 `ScheduledInspection`. **No backfill**: existing rows keep NULLs and retain their current cadence. `month_mode` is VARCHAR, not ENUM, so a future recurrence style needs no 3-step ENUM migration (rule 3). `INFORMATION_SCHEMA` column-existence checks — safe to re-run. Revision id `phase43_sched_recurrence`. Adds the five nullable recurrence-detail columns to `scheduled_inspections` (`weekdays`, `month_mode`, `day_of_month`, `nth_week`, `nth_weekday`) so weekly schedules can name their weekdays and monthly schedules can use either a day-of-month or an nth-weekday rule — see §5 `ScheduledInspection`. **No backfill**: existing rows keep NULLs and retain their current cadence. `month_mode` is VARCHAR, not ENUM, so a future recurrence style needs no 3-step ENUM migration (rule 3). `INFORMATION_SCHEMA` column-existence checks — safe to re-run.
@@ -1399,6 +1406,7 @@ timeout = 30
| 83 | **A bad `scheduled_inspection_id` must NEVER fail the inspection submission** | `_resolve_schedule()` in `app/api/inspections.py` drops an unknown or foreign link and logs a warning instead of returning 404/403. The app is offline-first: a completed inspection can sit in the outbox for days, during which the schedule may be deleted, reassigned, or rolled forward. Erroring would burn the 5 sync retries and permanently strand that inspection **and its photos** on the device. A missed fulfil is fixable from the web; a stranded submission is not. The ownership check still refuses to *link* a foreign schedule (one inspector must not fulfil another's) — it just accepts the inspection anyway. | | 83 | **A bad `scheduled_inspection_id` must NEVER fail the inspection submission** | `_resolve_schedule()` in `app/api/inspections.py` drops an unknown or foreign link and logs a warning instead of returning 404/403. The app is offline-first: a completed inspection can sit in the outbox for days, during which the schedule may be deleted, reassigned, or rolled forward. Erroring would burn the 5 sync retries and permanently strand that inspection **and its photos** on the device. A missed fulfil is fixable from the web; a stranded submission is not. The ownership check still refuses to *link* a foreign schedule (one inspector must not fulfil another's) — it just accepts the inspection anyway. |
| 82 | **A schedule's recurrence columns must be CLEARED when they don't apply to the chosen frequency** | `_apply_recurrence()` in `routes/scheduled_inspections.py` is the single write path for `frequency` + `weekdays`/`month_mode`/`day_of_month`/`nth_week`/`nth_weekday`, and it NULLs the blocks that don't apply. Setting `sched.frequency` directly (as create/edit used to) leaves stale settings behind — a weekly→monthly switch would keep `weekdays` and `recurrence_label` would lie. The hidden form blocks still POST their values, so client-side hiding is not enough. | | 82 | **A schedule's recurrence columns must be CLEARED when they don't apply to the chosen frequency** | `_apply_recurrence()` in `routes/scheduled_inspections.py` is the single write path for `frequency` + `weekdays`/`month_mode`/`day_of_month`/`nth_week`/`nth_weekday`, and it NULLs the blocks that don't apply. Setting `sched.frequency` directly (as create/edit used to) leaves stale settings behind — a weekly→monthly switch would keep `weekdays` and `recurrence_label` would lie. The hidden form blocks still POST their values, so client-side hiding is not enough. |
| 84 | **"Instructions" is a LABEL over `notes` — never rename the field, attribute, column or API key** | `ScheduledInspectionForm.notes` renders as "Instructions" and both the web execute page and the iPad say "Instructions". The wire key stays `notes` (`api/scheduled.py::_scheduled_payload`), which is what `APIScheduledInspection.notes` decodes into `LocalScheduledInspection.notes`; the iPad exposes it through a computed `instructions` accessor that also trims blank text. Renaming any of the storage identifiers would silently break the iPad decode — the field is `try?`-decoded, so it would fail to nil rather than throwing. | | 84 | **"Instructions" is a LABEL over `notes` — never rename the field, attribute, column or API key** | `ScheduledInspectionForm.notes` renders as "Instructions" and both the web execute page and the iPad say "Instructions". The wire key stays `notes` (`api/scheduled.py::_scheduled_payload`), which is what `APIScheduledInspection.notes` decodes into `LocalScheduledInspection.notes`; the iPad exposes it through a computed `instructions` accessor that also trims blank text. Renaming any of the storage identifiers would silently break the iPad decode — the field is `try?`-decoded, so it would fail to nil rather than throwing. |
| 85 | **`next_due_date` is mutable state, `end_date` is a fixed boundary — never conflate them** | `fulfill()` rewrites `next_due_date` after every completed inspection; `end_date` is set by the manager and never touched by the app. The old single label "Start / Due Date" said both at once, which is what users reported as confusing. The label now follows context — `form.next_due_date.label.text` is set to "Start Date" in `create()` and "Next Due Date" in `edit()`. Do not rename the `next_due_date` column to match a label: it is indexed, it is the API payload key the iPad decodes, and the reminder cron filters on it. |
| 81 | **Photo timestamp/geo overlay is burned at UPLOAD, never on `PATCH /issues/<id>/photos`** | That PATCH receives only path strings — the bytes are already in storage and the payload carries no capture metadata. Burning there would need a read-modify-write per key plus an overwrite-in-place primitive (`storage.save()` mints a NEW uuid key, and §22 requires key == DB path), and would risk a **double burn** since the endpoint is deliberately idempotent/retry-safe (rule 45). Stamp in `POST /photos/upload`, where the raw bytes + EXIF are in hand and each call writes exactly one already-stamped object. Stamping failures must always fall back to storing the ORIGINAL bytes — never lose a photo to a stamping bug. See §23. | | 81 | **Photo timestamp/geo overlay is burned at UPLOAD, never on `PATCH /issues/<id>/photos`** | That PATCH receives only path strings — the bytes are already in storage and the payload carries no capture metadata. Burning there would need a read-modify-write per key plus an overwrite-in-place primitive (`storage.save()` mints a NEW uuid key, and §22 requires key == DB path), and would risk a **double burn** since the endpoint is deliberately idempotent/retry-safe (rule 45). Stamp in `POST /photos/upload`, where the raw bytes + EXIF are in hand and each call writes exactly one already-stamped object. Stamping failures must always fall back to storing the ORIGINAL bytes — never lose a photo to a stamping bug. See §23. |
--- ---
+68
View File
@@ -0,0 +1,68 @@
JQC WEB — phase44: scheduled inspection End Date
================================================
Repo: lt_janitorial_quality_control
Deploy root: /home/jqc/janitorial_qc/
WEB ONLY. No iOS changes, no iPad rebuild.
NEW FILE
migrations/versions/phase44_scheduled_end_date.py
revision = 'phase44_sched_end_date'
down_revision = 'phase43_sched_recurrence' <- verified current HEAD
OVERWRITE
app/models/scheduled_inspection.py end_date column; is_within_end_date();
is_expired; expire_if_past_end_date();
fulfill() deactivates past the boundary
app/utils/forms.py end_date DateField + validate() rules
app/routes/scheduled_inspections.py _apply_recurrence() sets/clears end_date;
_reject_if_past_end_date() guard;
per-context next_due_date label;
run_reminders() expiry sweep; audit detail
app/api/scheduled.py 'end_date' in _scheduled_payload
app/templates/scheduled_inspections/form.html End Date field + JS toggle
app/templates/scheduled_inspections/list.html "Ends" column + "Ended" badge
CLAUDE.md
DEPLOY (migration step first, then code)
cd /home/jqc/janitorial_qc
git pull
# 1. MIGRATION
source venv/bin/activate
flask db current # expect phase43_sched_recurrence
flask db upgrade
flask db current # expect phase44_sched_end_date
# 2. CODE
sudo systemctl restart janitorial_qc
sudo systemctl status janitorial_qc --no-pager
ROLLBACK
flask db downgrade phase43_sched_recurrence # drops end_date, nothing else
VERIFY
1. New Schedule -> frequency "One-time": End Date row is HIDDEN,
date field reads "Start Date"
2. Switch frequency to Weekly: End Date row appears
3. Edit an existing schedule: date field reads "Next Due Date"
4. Validation:
- end date before the due date -> rejected
- end date on a one-time schedule (via curl/devtools) -> rejected
- Mon/Wed/Fri, pick a Tuesday, end date that same Tuesday
-> rejected, message names the Wednesday
5. List: "Ends" column shows the date, "No end" when blank, "—" for one-time
6. Existing schedules: unchanged, "No end", still Active, cadence identical
7. Boundary: set end date = next due date, complete the inspection
-> schedule goes Inactive, badge reads "Ended"
8. Cron sweep:
curl -X POST "https://jqc.ltservicesinc.com/scheduled-inspections/run?token=$DIGEST_SECRET"
-> JSON now includes "expired": N
-> a schedule past its end date that was never completed goes Inactive
and stops generating overdue alerts
NOTES
- Migration follows the phase42/phase43 idiom in this repo
(op.get_bind() + sa.text() + INFORMATION_SCHEMA). Flag if you want the
stricter plain-string-only form instead; 62 existing migrations use this one.
- api/scheduled.py now returns "end_date". Additive and safe: the iPad
decodes explicit CodingKeys, so current builds ignore the new key.
+3
View File
@@ -51,6 +51,9 @@ def _scheduled_payload(s):
'nth_week': s.nth_week, 'nth_week': s.nth_week,
'nth_weekday': s.nth_weekday, 'nth_weekday': s.nth_weekday,
'next_due_date': s.next_due_date.isoformat() if s.next_due_date else None, 'next_due_date': s.next_due_date.isoformat() if s.next_due_date else None,
# 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(), 'is_overdue': s.is_overdue(),
'notes': s.notes or None, '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 - 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 The *_notified flags make each of those fire at most once per occurrence and
reset when a recurring schedule rolls forward. reset when a recurring schedule rolls forward.
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 import calendar
@@ -85,6 +91,11 @@ class ScheduledInspection(db.Model):
nullable=True, index=True) nullable=True, index=True)
frequency = db.Column(db.Enum(*FREQUENCY_CHOICES), nullable=False, default='once') frequency = db.Column(db.Enum(*FREQUENCY_CHOICES), nullable=False, default='once')
next_due_date = db.Column(db.Date, nullable=False, index=True) 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) active = db.Column(db.Boolean, nullable=False, default=True)
notes = db.Column(db.Text, nullable=True) notes = db.Column(db.Text, nullable=True)
@@ -227,10 +238,48 @@ class ScheduledInspection(db.Model):
today = today or now_eastern().date() today = today or now_eastern().date()
return self.active and self.next_due_date < today return self.active and self.next_due_date < today
# ── 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): def fulfill(self):
"""Mark this occurrence complete. One-time schedules deactivate; """Mark this occurrence complete. One-time schedules deactivate;
recurring ones roll their due date forward past today and reset the 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() self.last_completed_at = now_eastern()
if self.frequency == 'once': if self.frequency == 'once':
self.active = False self.active = False
@@ -243,6 +292,12 @@ class ScheduledInspection(db.Model):
nxt = self.next_occurrence_after(nxt) nxt = self.next_occurrence_after(nxt)
guard += 1 guard += 1
self.next_due_date = nxt 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.advance_notified = False
self.due_notified = False self.due_notified = False
self.overdue_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.month_mode = sched.day_of_month = None
sched.nth_week = sched.nth_weekday = 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. # Snap the picked date forward onto the first matching occurrence.
sched.next_due_date = sched.align_due_date(form.next_due_date.data) 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): def _open_inspection_ids(schedules):
"""{schedule_id: inspection_id} for schedules with an inspection already """{schedule_id: inspection_id} for schedules with an inspection already
in progress, so the UI offers Continue instead of a duplicate Start.""" in progress, so the UI offers Continue instead of a duplicate Start."""
@@ -167,6 +189,10 @@ def index():
def create(): def create():
form = ScheduledInspectionForm() form = ScheduledInspectionForm()
_populate_choices(form) _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: if not form.next_due_date.data:
form.next_due_date.data = now_eastern().date() form.next_due_date.data = now_eastern().date()
@@ -181,11 +207,18 @@ def create():
created_by = current_user.id, created_by = current_user.id,
) )
_apply_recurrence(sched, form) _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.add(sched)
db.session.commit() db.session.commit()
log_action(ACTION_CREATE, 'ScheduledInspection', sched.id, log_action(ACTION_CREATE, 'ScheduledInspection', sched.id,
f'{sched.template.name} @ {sched.facility.name}', f'{sched.template.name} @ {sched.facility.name}',
f'freq={sched.recurrence_label}; due={sched.next_due_date}; ' f'freq={sched.recurrence_label}; due={sched.next_due_date}; '
f'end={sched.end_date or ""}; '
f'inspector={sched.inspector_id}') f'inspector={sched.inspector_id}')
logger.info('SCHED INSP | create | by=%s | id=%s', current_user.username, sched.id) logger.info('SCHED INSP | create | by=%s | id=%s', current_user.username, sched.id)
@@ -213,6 +246,7 @@ def edit(schedule_id):
abort(404) abort(404)
form = ScheduledInspectionForm(obj=sched) form = ScheduledInspectionForm(obj=sched)
_populate_choices(form) _populate_choices(form)
form.next_due_date.label.text = 'Next Due Date'
if request.method == 'GET': if request.method == 'GET':
# obj= copies the raw CSV column into a multi-select field; hand it the # obj= copies the raw CSV column into a multi-select field; hand it the
# parsed int list instead so the checkboxes pre-tick correctly. # 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.notes = (form.notes.data or '').strip() or None
sched.active = form.active.data sched.active = form.active.data
_apply_recurrence(sched, form) _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() db.session.commit()
log_action(ACTION_UPDATE, 'ScheduledInspection', sched.id, log_action(ACTION_UPDATE, 'ScheduledInspection', sched.id,
f'{sched.template.name} @ {sched.facility.name}', f'{sched.template.name} @ {sched.facility.name}',
f'freq={sched.recurrence_label}; due={sched.next_due_date}; ' f'freq={sched.recurrence_label}; due={sched.next_due_date}; '
f'end={sched.end_date or ""}; '
f'active={sched.active}') f'active={sched.active}')
# Notify the inspector if the assignment changed to them. # Notify the inspector if the assignment changed to them.
@@ -333,10 +377,23 @@ def run_reminders():
abort(403) abort(403)
today = now_eastern().date() 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() 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 # Cache admin/director recipients for overdue alerts
managers = User.query.filter( managers = User.query.filter(
User.role.in_(['admin', 'director']), User.active == True # noqa: E712 User.role.in_(['admin', 'director']), User.active == True # noqa: E712
@@ -395,6 +452,6 @@ def run_reminders():
sent['overdue'] += 1 sent['overdue'] += 1
db.session.commit() db.session.commit()
logger.info('SCHED INSP | reminders | advance=%s due=%s overdue=%s', logger.info('SCHED INSP | reminders | advance=%s due=%s overdue=%s expired=%s',
sent['advance'], sent['due'], sent['overdue']) sent['advance'], sent['due'], sent['overdue'], sent['expired'])
return {'ok': True, 'sent': sent}, 200 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.label(class="form-label fw-semibold") }}
{{ form.next_due_date(class="form-control", type="date") }} {{ form.next_due_date(class="form-control", type="date") }}
{% for e in form.next_due_date.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %} {% for e in form.next_due_date.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
<div class="form-text">Snapped forward to the first matching day.</div> <div 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>
</div> </div>
@@ -201,6 +220,7 @@
document.querySelector('[name="frequency"]'); document.querySelector('[name="frequency"]');
var weekly = document.getElementById('weekly_block'); var weekly = document.getElementById('weekly_block');
var monthly = document.getElementById('monthly_block'); var monthly = document.getElementById('monthly_block');
var endRow = document.getElementById('end_date_row');
if (!freq || !weekly || !monthly) { return; } if (!freq || !weekly || !monthly) { return; }
var domRadio = document.getElementById('month_mode_day'); var domRadio = document.getElementById('month_mode_day');
@@ -217,6 +237,8 @@
function syncFrequency() { function syncFrequency() {
weekly.hidden = freq.value !== 'weekly'; weekly.hidden = freq.value !== 'weekly';
monthly.hidden = freq.value !== 'monthly'; monthly.hidden = freq.value !== 'monthly';
// End date is a recurring-only concept.
if (endRow) { endRow.hidden = freq.value === 'once'; }
syncMonthMode(); syncMonthMode();
} }
@@ -31,6 +31,7 @@
<th>Inspector</th> <th>Inspector</th>
<th>Frequency</th> <th>Frequency</th>
<th>Next Due</th> <th>Next Due</th>
<th>Ends</th>
<th>Status</th> <th>Status</th>
<th class="text-end"></th> <th class="text-end"></th>
</tr> </tr>
@@ -52,9 +53,22 @@
<span class="badge bg-warning text-dark ms-1">Due soon</span> <span class="badge bg-warning text-dark ms-1">Due soon</span>
{% endif %} {% endif %}
</td> </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> <td>
{# Three states, not two: "Ended" distinguishes a schedule that ran
its course from one a manager switched off. #}
{% if s.active %} {% if s.active %}
<span class="badge bg-success">Active</span> <span class="badge bg-success">Active</span>
{% elif s.is_expired %}
<span class="badge bg-dark" title="Passed its end date">Ended</span>
{% else %} {% else %}
<span class="badge bg-secondary">Inactive</span> <span class="badge bg-secondary">Inactive</span>
{% endif %} {% endif %}
+18
View File
@@ -339,6 +339,10 @@ class ScheduledInspectionForm(FlaskForm):
('weekly', 'Weekly'), ('monthly', 'Monthly'), ('weekly', 'Weekly'), ('monthly', 'Monthly'),
], validators=[DataRequired()]) ], validators=[DataRequired()])
next_due_date = DateField('Start / Due Date', 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 # UI label only. The field name, the ScheduledInspection.notes attribute and
# the scheduled_inspections.notes column all stay `notes` — renaming any of # the scheduled_inspections.notes column all stay `notes` — renaming any of
# them would break the API payload key the iPad decodes. # them would break the API payload key the iPad decodes.
@@ -387,6 +391,20 @@ class ScheduledInspectionForm(FlaskForm):
elif not self.day_of_month.data: elif not self.day_of_month.data:
self.day_of_month.errors.append('Enter a day of the month (131).') self.day_of_month.errors.append('Enter a day of the month (131).')
ok = False 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 return ok
@@ -0,0 +1,56 @@
"""phase44 — scheduled inspection end date
Adds `end_date` to `scheduled_inspections`:
end_date DATE NULL -- last date this schedule may produce an occurrence
Separates the two ideas that `next_due_date` was carrying at once. `next_due_date`
is *mutable state* ScheduledInspection.fulfill() rewrites it after every
completed inspection 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 no backfill and no schedule
changes cadence on deploy.
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).
Uses an INFORMATION_SCHEMA column-existence check safe to re-run.
Additive only: no existing column is renamed, retyped or dropped. In particular
`next_due_date` keeps its name and its index it is the API payload key the
iPad decodes and is used by the reminder cron.
"""
revision = 'phase44_sched_end_date'
down_revision = 'phase43_sched_recurrence'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def _column_exists(conn, table, column):
result = conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() "
"AND TABLE_NAME = :t AND COLUMN_NAME = :c"
), {"t": table, "c": column})
return result.scalar() > 0
def upgrade():
bind = op.get_bind()
if not _column_exists(bind, 'scheduled_inspections', 'end_date'):
op.execute(sa.text(
"ALTER TABLE scheduled_inspections "
"ADD COLUMN end_date DATE NULL AFTER next_due_date"
))
def downgrade():
bind = op.get_bind()
if _column_exists(bind, 'scheduled_inspections', 'end_date'):
op.execute(sa.text(
"ALTER TABLE scheduled_inspections DROP COLUMN end_date"
))