Jul 27 - Update code for scheduled tasks 2
This commit is contained in:
@@ -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. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 (1–31).')
|
self.day_of_month.errors.append('Enter a day of the month (1–31).')
|
||||||
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"
|
||||||
|
))
|
||||||
Reference in New Issue
Block a user