diff --git a/CLAUDE.md b/CLAUDE.md index c45cf7e..445271b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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: - 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. +- **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`. - **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). @@ -863,6 +866,10 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif → 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 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. | | 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. | +| 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//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. | --- diff --git a/DEPLOYMENT_MAP.txt b/DEPLOYMENT_MAP.txt new file mode 100644 index 0000000..9bf65f3 --- /dev/null +++ b/DEPLOYMENT_MAP.txt @@ -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. diff --git a/app/api/scheduled.py b/app/api/scheduled.py index e6cd533..566081f 100644 --- a/app/api/scheduled.py +++ b/app/api/scheduled.py @@ -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, } diff --git a/app/models/scheduled_inspection.py b/app/models/scheduled_inspection.py index 4f74e32..62f64ea 100644 --- a/app/models/scheduled_inspection.py +++ b/app/models/scheduled_inspection.py @@ -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 diff --git a/app/routes/scheduled_inspections.py b/app/routes/scheduled_inspections.py index f0d9d7c..1b7e142 100644 --- a/app/routes/scheduled_inspections.py +++ b/app/routes/scheduled_inspections.py @@ -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 diff --git a/app/templates/scheduled_inspections/form.html b/app/templates/scheduled_inspections/form.html index 0379a8c..2497826 100644 --- a/app/templates/scheduled_inspections/form.html +++ b/app/templates/scheduled_inspections/form.html @@ -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 %}
{{ e }}
{% endfor %} -
Snapped forward to the first matching day.
+
+ Snapped forward to the first matching day. + Advances automatically after each completed inspection. +
+ + + + {# ── 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. #} + @@ -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(); } diff --git a/app/templates/scheduled_inspections/list.html b/app/templates/scheduled_inspections/list.html index 601a166..f11b3ea 100644 --- a/app/templates/scheduled_inspections/list.html +++ b/app/templates/scheduled_inspections/list.html @@ -31,6 +31,7 @@ Inspector Frequency Next Due + Ends Status @@ -52,9 +53,22 @@ Due soon {% endif %} + + {% if s.frequency == 'once' %} + + {% elif s.end_date %} + {{ s.end_date.strftime('%b %d, %Y') }} + {% else %} + No end + {% endif %} + + {# Three states, not two: "Ended" distinguishes a schedule that ran + its course from one a manager switched off. #} {% if s.active %} Active + {% elif s.is_expired %} + Ended {% else %} Inactive {% endif %} diff --git a/app/utils/forms.py b/app/utils/forms.py index 3f30e1c..1d24de6 100644 --- a/app/utils/forms.py +++ b/app/utils/forms.py @@ -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 (1–31).') 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 diff --git a/migrations/versions/phase44_scheduled_end_date.py b/migrations/versions/phase44_scheduled_end_date.py new file mode 100644 index 0000000..b4f09b5 --- /dev/null +++ b/migrations/versions/phase44_scheduled_end_date.py @@ -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" + ))