From f4c80cfcefe58e56315abe5cd898e0ca37bfc213 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Tue, 4 Aug 2026 13:37:29 -0400 Subject: [PATCH] Aug 4 - Update code to follow up - MT12b --- app/api/inspections.py | 13 + app/api/scheduled.py | 151 +++++++ app/models/inspection_schedule.py | 31 ++ app/routes/inspection_schedules.py | 23 +- app/templates/inspection_schedules/index.html | 13 +- .../phase48_schedule_parent_inspection.py | 115 +++++ tests/test_schedule_follow_up.py | 418 ++++++++++++++++++ 7 files changed, 762 insertions(+), 2 deletions(-) create mode 100644 migrations/versions/phase48_schedule_parent_inspection.py create mode 100644 tests/test_schedule_follow_up.py diff --git a/app/api/inspections.py b/app/api/inspections.py index 6f1a2c8..c8c74a8 100644 --- a/app/api/inspections.py +++ b/app/api/inspections.py @@ -467,6 +467,19 @@ def create_inspection(): _sched = _resolve_schedule(_sched_id, user) inspection_schedule_id = _sched.id if _sched else None + # phase48 — inherit the follow-up link from the schedule when the client + # did not send one. A schedule created by "Schedule Follow-up" knows + # which inspection it answers, so the link must not depend on the client + # remembering to pass it: an older build, or a draft resumed after the + # cached row was refreshed, would otherwise submit a plain inspection + # and leave the parent flagged forever. Never overrides an explicit + # parent_inspection_id. + if not parent_inspection_id and _sched is not None and _sched.parent_inspection_id: + parent_inspection_id = _sched.parent_inspection_id + logger.info('API INSPECTIONS | parent inherited from schedule | ' + 'schedule=%s | parent=%s | user=%s', + _sched.id, parent_inspection_id, user.username) + inspection = Inspection( template_id = template_id, facility_id = facility_id, diff --git a/app/api/scheduled.py b/app/api/scheduled.py index a3f7310..b7e118e 100644 --- a/app/api/scheduled.py +++ b/app/api/scheduled.py @@ -24,11 +24,17 @@ app/models/inspection_schedule.py for the full lifecycle. """ import logging +from datetime import datetime from flask import Blueprint, request, g +from app import db +from app.models.inspection import Inspection from app.models.inspection_schedule import InspectionSchedule from app.api.errors import api_ok, api_error from app.api.decorators import jwt_required +from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE +from app.utils.scope import get_inspector_scope +from app.utils.time_utils import now_eastern logger = logging.getLogger(__name__) @@ -69,6 +75,10 @@ def _scheduled_payload(s): # 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(), + # phase48 — non-NULL when this schedule is a planned follow-up of a + # completed inspection. The iPad uses it to badge the row and to open + # the parent from the schedule detail. + 'parent_inspection_id': s.parent_inspection_id, 'notes': s.notes or None, } @@ -133,3 +143,144 @@ def list_scheduled(): return api_ok({'scheduled': payload, 'total': total, 'limit': limit, 'offset': offset}) + + +# ── Create a scheduled follow-up (phase48) ──────────────────────────────────── + +@bp.route('/scheduled-inspections/follow-up', methods=['POST']) +@jwt_required +def create_follow_up(): + """ + Plan a follow-up re-inspection of a completed inspection for a later date. + + Backs "Schedule Follow-up" in the iPad's inspection history detail, the + deferred twin of "Re-inspect Now". Creates a one-time (`frequency='once'`), + plan-mode schedule carrying `parent_inspection_id`, so the inspection + eventually started from it is a true linked re-inspection. + + Deliberately narrow: this is NOT a general schedule-creation endpoint. The + facility, area, template and assignee are all derived from the parent + inspection rather than taken from the client, so a follow-up can only ever + target the thing it is a follow-up of. Recurring schedules stay web-only + (`@project_manager_required`). + + Mode is forced to 'plan', never 'auto': a follow-up is something a person + goes and does, and an auto schedule would drop an in-progress inspection + into the queue unannounced on the due date. + + Request body + ------------ + parent_inspection_id int required — the completed inspection to follow up + due_date str required — ISO date (YYYY-MM-DD), today or later + notes str optional — what the follow-up should address + + Response 200 (reused existing) / 201 (created) + --------------------------------------------- + { "ok": true, "data": { "scheduled": {...}, "created": true } } + """ + user = g.api_user + + # Auditor is read-only everywhere else; keep it that way here. + if user.role not in {'admin', 'director', 'inspector', 'project_manager'}: + return api_error('Access denied', 403) + + body = request.get_json(silent=True) or {} + + parent_id = body.get('parent_inspection_id') + if not isinstance(parent_id, int): + return api_error('parent_inspection_id is required', 400) + + parent = db.session.get(Inspection, parent_id) + if parent is None: + return api_error('Inspection not found', 404) + + # An inspector may only schedule a follow-up of their own work, and only + # within their assigned contracts — the same two gates the rest of the + # mobile API applies. Managers are unrestricted, matching the web. + if user.role == 'inspector': + if parent.inspector_id != user.id: + return api_error('Access denied', 403) + fids = get_inspector_scope(user) + if not fids or parent.facility_id not in fids: + return api_error('Access denied', 403) + + # A follow-up only makes sense once there is something to follow up on. + if parent.status != 'completed': + return api_error('Only a completed inspection can have a follow-up ' + 'scheduled', 400) + + due_raw = (body.get('due_date') or '').strip() + try: + due_date = datetime.strptime(due_raw, '%Y-%m-%d').date() + except ValueError: + return api_error('due_date must be an ISO date (YYYY-MM-DD)', 400) + + # Today is allowed — "later today" is a legitimate plan; yesterday is not. + if due_date < now_eastern().date(): + return api_error('due_date cannot be in the past', 400) + + notes = (body.get('notes') or '').strip() or None + + # Idempotent: the iPad may retry a request whose response was lost, and a + # second identical schedule would put a duplicate row in the inspector's + # Scheduled list with no way to tell them apart. Reuse the existing active + # follow-up for this parent instead, updating the date they just picked. + existing = (InspectionSchedule.query + .filter_by(parent_inspection_id=parent.id, active=True) + .order_by(InspectionSchedule.id.desc()) + .first()) + if existing is not None: + existing.set_next_run_date(due_date) + if notes: + existing.notes = notes + # A moved due date is a new occurrence — the reminders already sent for + # the old one no longer apply. + existing.advance_notified = False + existing.due_notified = False + existing.overdue_notified = False + db.session.commit() + log_action(ACTION_UPDATE, 'InspectionSchedule', existing.id, existing.name, + f'follow-up rescheduled via mobile API by {user.username}; ' + f'parent_inspection_id={parent.id}; due={due_date}') + logger.info('API SCHEDULED | follow-up updated | schedule=%s | ' + 'parent=%s | due=%s | user=%s', + existing.id, parent.id, due_date, user.username) + return api_ok({'scheduled': _scheduled_payload(existing), + 'created': False}) + + fac_name = parent.facility.name if parent.facility else 'facility' + sched = InspectionSchedule( + # MT requires a name (ST's table does not). Build one rather than asking + # the client for it, so the row is identifiable in the web schedule list + # without the iPad needing to know MT's schema. + name = f'Follow-up: {fac_name} (inspection #{parent.id})', + facility_id = parent.facility_id, + area_id = parent.area_id, + template_id = parent.template_id, + # Assign to whoever performed the original — they are the one being + # asked to put it right. Falls back to the caller when the parent has + # no inspector (its account was deleted). + inspector_id = parent.inspector_id or user.id, + frequency = 'once', + mode = 'plan', + active = True, + notes = notes, + parent_inspection_id = parent.id, + created_by = user.id, + created_at = now_eastern(), + ) + # set_next_run_date() rather than a raw next_run_at so the due date gets the + # schedule's standard time-of-day (06:00 for a row with no next_run_at yet). + sched.set_next_run_date(due_date) + db.session.add(sched) + db.session.commit() + + log_action(ACTION_CREATE, 'InspectionSchedule', sched.id, sched.name, + f'follow-up created via mobile API by {user.username}; ' + f'parent_inspection_id={parent.id}; facility_id={parent.facility_id}; ' + f'due={due_date}') + logger.info('API SCHEDULED | follow-up created | schedule=%s | parent=%s | ' + 'facility=%s | due=%s | user=%s', + sched.id, parent.id, parent.facility_id, due_date, user.username) + + return api_ok({'scheduled': _scheduled_payload(sched), 'created': True}, 201) diff --git a/app/models/inspection_schedule.py b/app/models/inspection_schedule.py index 654ec76..b7cb9db 100644 --- a/app/models/inspection_schedule.py +++ b/app/models/inspection_schedule.py @@ -151,6 +151,26 @@ class InspectionSchedule(db.Model): mode = db.Column(db.Enum('auto', 'plan'), nullable=False, default='auto') notes = db.Column(db.Text, nullable=True) + # ── Follow-up link (phase48) ───────────────────────────────────────────── + # Set when this schedule was created as a follow-up of a specific completed + # inspection ("Schedule Follow-up" in the iPad's history detail — the + # deferred twin of "Re-inspect Now"). The inspection eventually started from + # this schedule inherits it as its own parent_inspection_id, so the run + # lands as a true linked re-inspection: pre-filled from the parent, and + # clearing the parent's follow_up_required on submit. NULL = an ordinary + # schedule, which is what every pre-phase48 row is. + parent_inspection_id = db.Column( + db.Integer, + # use_alter + an explicit name: inspections and inspection_schedules now + # reference each other, so metadata-driven CREATE/DROP cannot topologically + # sort them. The name matches the constraint phase48 creates, so the ORM's + # view of the schema and the migration's agree. + db.ForeignKey('inspections.id', ondelete='SET NULL', + name='fk_inspection_schedules_parent_inspection', + use_alter=True), + nullable=True, index=True, + ) + created_by = db.Column( db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True @@ -196,6 +216,17 @@ class InspectionSchedule(db.Model): area = db.relationship('Area', foreign_keys=[area_id]) inspector = db.relationship('User', foreign_keys=[inspector_id]) creator = db.relationship('User', foreign_keys=[created_by]) + # phase48. Explicit foreign_keys is required, not optional: inspections and + # inspection_schedules now reference each other (Inspection + # .inspection_schedule_id points here, parent_inspection_id points back), so + # SQLAlchemy cannot infer the join for either side. + parent_inspection = db.relationship('Inspection', + foreign_keys=[parent_inspection_id]) + + @property + def is_follow_up(self): + """True when this schedule was created to follow up an inspection.""" + return self.parent_inspection_id is not None FREQUENCY_LABELS = { 'once': 'One-time', diff --git a/app/routes/inspection_schedules.py b/app/routes/inspection_schedules.py index 2fe0fa5..9b9184e 100644 --- a/app/routes/inspection_schedules.py +++ b/app/routes/inspection_schedules.py @@ -230,6 +230,12 @@ def _materialise(schedule: InspectionSchedule, when: datetime) -> Inspection: status = 'in_progress', notes = schedule.notes, inspection_schedule_id = schedule.id, # phase43 — link back to the plan + # phase48 — a schedule created by "Schedule Follow-up" carries the + # inspection it answers. Inheriting it here is what makes the run a real + # linked re-inspection: execute() pre-fills from the parent and submit + # clears the parent's follow_up_required. NULL for ordinary schedules, + # which is every pre-phase48 row. + parent_inspection_id = schedule.parent_inspection_id, ) db.session.add(inspection) db.session.flush() # assign inspection.id without committing @@ -546,6 +552,18 @@ def start(schedule_id): flash('The template for this schedule has no form fields yet.', 'warning') return redirect(url_for('inspection_schedules.index')) + # Already started but not submitted? Resume it rather than opening a second + # inspection against the same occurrence. Without this, a manager and the + # inspector both pressing Start — or one double-tap — leaves two in_progress + # rows against one schedule, only one of which fulfils it on submit. + existing = (Inspection.query + .filter_by(inspection_schedule_id=schedule.id, status='in_progress') + .order_by(Inspection.id.desc()) + .first()) + if existing is not None: + flash('Resuming the inspection you already started for this schedule.', 'info') + return redirect(url_for('inspections.execute', inspection_id=existing.id)) + inspection = Inspection( template_id = schedule.template_id, facility_id = schedule.facility_id, @@ -555,12 +573,15 @@ def start(schedule_id): status = 'in_progress', notes = schedule.notes, inspection_schedule_id = schedule.id, + # phase48 — see _materialise(). + parent_inspection_id = schedule.parent_inspection_id, ) db.session.add(inspection) db.session.commit() log_action(ACTION_CREATE, 'Inspection', inspection.id, f'{inspection.template.name} @ {inspection.facility.name}', - f'started from inspection_schedule_id={schedule.id}') + f'started from inspection_schedule_id={schedule.id}; ' + f'parent_inspection_id={schedule.parent_inspection_id}') logger.info('INSPECTION SCHEDULE | start | schedule=%s | inspection=%s | by=%s', schedule.id, inspection.id, current_user.username) flash('Inspection started from schedule. Complete and submit the form below.', 'info') diff --git a/app/templates/inspection_schedules/index.html b/app/templates/inspection_schedules/index.html index beb21d7..d1f6481 100644 --- a/app/templates/inspection_schedules/index.html +++ b/app/templates/inspection_schedules/index.html @@ -38,7 +38,18 @@ {% for s in schedules %} - {{ s.name }} + + {{ s.name }} + {% if s.is_follow_up %} + {# phase48 — a schedule planned as the deferred twin of + "Re-inspect Now". Starting it produces a linked re-inspection. #} + + Follow-up #{{ s.parent_inspection_id }} + + {% endif %} + {{ s.template.name if s.template else '—' }} {{ s.facility.name if s.facility else '—' }} diff --git a/migrations/versions/phase48_schedule_parent_inspection.py b/migrations/versions/phase48_schedule_parent_inspection.py new file mode 100644 index 0000000..5578de6 --- /dev/null +++ b/migrations/versions/phase48_schedule_parent_inspection.py @@ -0,0 +1,115 @@ +"""phase48 — scheduled follow-up: link a schedule back to its parent inspection + +Ports single-tenant phase45 onto MT's `inspection_schedules` table. Adds: + + parent_inspection_id INT NULL -- inspection this schedule is a follow-up of + +Lets a follow-up be *planned for a later date* rather than started immediately — +the deferred twin of "Re-inspect Now". A one-time schedule carrying this column +is created via POST /api/v1/scheduled-inspections/follow-up; when the inspector +eventually starts it, the resulting inspection inherits `parent_inspection_id`, +so it lands as a true linked re-inspection: pre-filled from the parent, and +clearing the parent's `follow_up_required` on submit. + +Without the column the scheduled run would be an ordinary inspection — no link, +no prefill, and the parent's follow-up flag would stay set forever. + +Depends on phase45's `once` frequency: a follow-up is a single planned visit, +not a recurrence. + +NULL means "not a follow-up", which is what every existing row is, so there is +no backfill and no schedule changes behaviour on deploy. + +ON DELETE SET NULL: deleting the parent inspection must not cascade away a +schedule the inspector still has to perform — it just stops being a follow-up. +This mirrors `inspections.inspection_schedule_id`, which points the other way +with the same rule, so neither side of the pair can delete the other's history. + +Uses INFORMATION_SCHEMA existence checks — safe to re-run on every tenant DB. +Additive only: nothing is renamed, retyped or dropped. +""" + +revision = 'phase48_schedule_parent_inspection' +down_revision = 'phase47_schedule_end_date' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +_TABLE = 'inspection_schedules' +_COLUMN = 'parent_inspection_id' +_FK = 'fk_inspection_schedules_parent_inspection' +_INDEX = 'ix_inspection_schedules_parent_inspection_id' + + +def _table_exists(conn, table): + return conn.execute(sa.text( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES " + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t" + ), {"t": table}).scalar() > 0 + + +def _column_exists(conn, table, column): + return 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}).scalar() > 0 + + +def _constraint_exists(conn, table, name): + return conn.execute(sa.text( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS " + "WHERE TABLE_SCHEMA = DATABASE() " + "AND TABLE_NAME = :t AND CONSTRAINT_NAME = :n" + ), {"t": table, "n": name}).scalar() > 0 + + +def _index_exists(conn, table, name): + return conn.execute(sa.text( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS " + "WHERE TABLE_SCHEMA = DATABASE() " + "AND TABLE_NAME = :t AND INDEX_NAME = :n" + ), {"t": table, "n": name}).scalar() > 0 + + +def upgrade(): + bind = op.get_bind() + if not _table_exists(bind, _TABLE): + return + + if not _column_exists(bind, _TABLE, _COLUMN): + op.execute(sa.text( + f"ALTER TABLE {_TABLE} ADD COLUMN {_COLUMN} INT NULL AFTER notes" + )) + + # Named explicitly so the downgrade and the re-run check can find it. MySQL + # auto-creates an index for a foreign key, but only when no usable index + # exists; creating it first means the name is ours and predictable. + if not _index_exists(bind, _TABLE, _INDEX): + op.execute(sa.text( + f"CREATE INDEX {_INDEX} ON {_TABLE} ({_COLUMN})" + )) + + if not _constraint_exists(bind, _TABLE, _FK): + op.execute(sa.text( + f"ALTER TABLE {_TABLE} ADD CONSTRAINT {_FK} " + f"FOREIGN KEY ({_COLUMN}) REFERENCES inspections(id) " + f"ON DELETE SET NULL" + )) + + +def downgrade(): + bind = op.get_bind() + if not _table_exists(bind, _TABLE): + return + + # FK first — MySQL refuses to drop a column or index still referenced by one. + if _constraint_exists(bind, _TABLE, _FK): + op.execute(sa.text(f"ALTER TABLE {_TABLE} DROP FOREIGN KEY {_FK}")) + if _index_exists(bind, _TABLE, _INDEX): + op.execute(sa.text(f"DROP INDEX {_INDEX} ON {_TABLE}")) + if _column_exists(bind, _TABLE, _COLUMN): + op.execute(sa.text(f"ALTER TABLE {_TABLE} DROP COLUMN {_COLUMN}")) diff --git a/tests/test_schedule_follow_up.py b/tests/test_schedule_follow_up.py new file mode 100644 index 0000000..4511a07 --- /dev/null +++ b/tests/test_schedule_follow_up.py @@ -0,0 +1,418 @@ +""" +tests/test_schedule_follow_up.py +--------------------------------- +Behaviour tests for phase48 — scheduled follow-up. + +Runs on the in-memory SQLite app fixture (multi-tenancy inert). Covers: + + * POST /api/v1/scheduled-inspections/follow-up creates a one-time, plan-mode + schedule carrying parent_inspection_id, with facility/area/template/assignee + all derived from the parent rather than taken from the client + * idempotency — a retry reuses the existing active follow-up and updates its + date instead of creating a duplicate row + * validation — missing/incorrect parent, non-completed parent, bad or past + due_date, auditor role + * an inspector may only follow up their own work + * starting the schedule from the web produces an inspection that INHERITS + parent_inspection_id (what makes it a real linked re-inspection) + * start() resumes an in-progress run instead of opening a second one + * the cron materialiser inherits the link too + * the mobile API infers the parent from the schedule when the client omits it +""" + +from datetime import date, datetime, timedelta + +import pytest + + +@pytest.fixture +def client(app): + """Fresh schema + test client for each test (isolated in-memory DB).""" + app.config['DIGEST_SECRET'] = 'test-digest' + with app.app_context(): + from app import db + db.drop_all() + db.create_all() + yield app.test_client() + db.session.remove() + + +def _seed(suffix='a', role='inspector'): + from app import db + from app.models.user import User + from app.models.facility import Facility + from app.models.inspection import InspectionTemplate + + user = User(username=f'u_{suffix}', full_name='Ivy Inspector', + email=f'u_{suffix}@example.com', role=role, active=True) + user.set_password('pw-correct1') + tmpl = InspectionTemplate(name='Restroom Check', active=True, + form_schema=[{'id': 'f1', 'type': 'rating_5', 'label': 'Clean'}]) + fac = Facility(name='Main Office', active=True) + db.session.add_all([user, tmpl, fac]) + db.session.commit() + return user, tmpl, fac + + +def _completed_inspection(user, tmpl, fac, area_id=None): + from app import db + from app.models.inspection import Inspection + from app.utils.time_utils import now_eastern + + insp = Inspection(template_id=tmpl.id, facility_id=fac.id, area_id=area_id, + inspector_id=user.id, inspection_date=now_eastern(), + status='completed', completed_at=now_eastern(), + overall_score=62.5, follow_up_required=True) + db.session.add(insp) + db.session.commit() + return insp + + +def _auth(user): + from app.api.jwt_utils import generate_access_token + return {'Authorization': f'Bearer {generate_access_token(user)}'} + + +def _post_follow_up(client, user, **body): + return client.post('/api/v1/scheduled-inspections/follow-up', + json=body, headers=_auth(user)) + + +# ── Creation ───────────────────────────────────────────────────────────────── + +def test_follow_up_creates_one_time_plan_schedule_from_the_parent(client): + from app.models.inspection_schedule import InspectionSchedule + + user, tmpl, fac = _seed('mk', role='project_manager') + parent = _completed_inspection(user, tmpl, fac) + due = date.today() + timedelta(days=7) + + resp = _post_follow_up(client, user, + parent_inspection_id=parent.id, + due_date=due.isoformat(), + notes='Recheck the stalls') + assert resp.status_code == 201 + data = resp.get_json()['data'] + assert data['created'] is True + + s = InspectionSchedule.query.get(data['scheduled']['id']) + assert s.parent_inspection_id == parent.id + assert s.is_follow_up is True + # Everything derived from the parent, nothing taken from the client. + assert s.facility_id == parent.facility_id + assert s.template_id == parent.template_id + assert s.inspector_id == parent.inspector_id + assert s.area_id == parent.area_id + # A follow-up is a single planned visit the inspector goes and does. + assert s.frequency == 'once' + assert s.mode == 'plan' + assert s.active is True + assert s.due_date == due + assert s.notes == 'Recheck the stalls' + assert str(parent.id) in s.name + + +def test_follow_up_ignores_client_supplied_facility_and_template(client): + """The endpoint is narrow on purpose — a follow-up can only target the thing + it is a follow-up of.""" + from app import db + from app.models.facility import Facility + from app.models.inspection import InspectionTemplate + from app.models.inspection_schedule import InspectionSchedule + + user, tmpl, fac = _seed('narrow', role='admin') + parent = _completed_inspection(user, tmpl, fac) + + other_fac = Facility(name='Other Site', active=True) + other_tmpl = InspectionTemplate(name='Other', active=True, form_schema=[]) + db.session.add_all([other_fac, other_tmpl]) + db.session.commit() + + resp = _post_follow_up(client, user, + parent_inspection_id=parent.id, + due_date=(date.today() + timedelta(days=3)).isoformat(), + facility_id=other_fac.id, + template_id=other_tmpl.id, + frequency='daily', mode='auto') + s = InspectionSchedule.query.get(resp.get_json()['data']['scheduled']['id']) + assert s.facility_id == fac.id + assert s.template_id == tmpl.id + assert s.frequency == 'once' + assert s.mode == 'plan' + + +def test_follow_up_is_idempotent_on_retry(client): + from app.models.inspection_schedule import InspectionSchedule + + user, tmpl, fac = _seed('idem', role='admin') + parent = _completed_inspection(user, tmpl, fac) + first_due = date.today() + timedelta(days=5) + second_due = date.today() + timedelta(days=9) + + r1 = _post_follow_up(client, user, parent_inspection_id=parent.id, + due_date=first_due.isoformat()) + assert r1.status_code == 201 and r1.get_json()['data']['created'] is True + + r2 = _post_follow_up(client, user, parent_inspection_id=parent.id, + due_date=second_due.isoformat(), notes='Updated') + assert r2.status_code == 200 + assert r2.get_json()['data']['created'] is False + + rows = InspectionSchedule.query.filter_by(parent_inspection_id=parent.id).all() + assert len(rows) == 1 + assert rows[0].due_date == second_due + assert rows[0].notes == 'Updated' + + +def test_follow_up_reschedule_rearms_reminders(client): + from app import db + from app.models.inspection_schedule import InspectionSchedule + + user, tmpl, fac = _seed('rearm', role='admin') + parent = _completed_inspection(user, tmpl, fac) + _post_follow_up(client, user, parent_inspection_id=parent.id, + due_date=(date.today() + timedelta(days=2)).isoformat()) + + s = InspectionSchedule.query.filter_by(parent_inspection_id=parent.id).one() + s.advance_notified = s.due_notified = s.overdue_notified = True + db.session.commit() + + _post_follow_up(client, user, parent_inspection_id=parent.id, + due_date=(date.today() + timedelta(days=12)).isoformat()) + db.session.expire_all() + s = InspectionSchedule.query.filter_by(parent_inspection_id=parent.id).one() + assert s.advance_notified is False + assert s.due_notified is False + assert s.overdue_notified is False + + +# ── Validation ─────────────────────────────────────────────────────────────── + +def test_follow_up_requires_a_completed_parent(client): + from app import db + from app.models.inspection import Inspection + from app.utils.time_utils import now_eastern + + user, tmpl, fac = _seed('draft', role='admin') + draft = Inspection(template_id=tmpl.id, facility_id=fac.id, + inspector_id=user.id, inspection_date=now_eastern(), + status='in_progress') + db.session.add(draft) + db.session.commit() + + r = _post_follow_up(client, user, parent_inspection_id=draft.id, + due_date=(date.today() + timedelta(days=1)).isoformat()) + assert r.status_code == 400 + + +def test_follow_up_rejects_bad_input(client): + user, tmpl, fac = _seed('bad', role='admin') + parent = _completed_inspection(user, tmpl, fac) + ok_due = (date.today() + timedelta(days=1)).isoformat() + + # Missing parent id. + assert _post_follow_up(client, user, due_date=ok_due).status_code == 400 + # Non-integer parent id. + assert _post_follow_up(client, user, parent_inspection_id='7', + due_date=ok_due).status_code == 400 + # Unknown parent. + assert _post_follow_up(client, user, parent_inspection_id=999999, + due_date=ok_due).status_code == 404 + # Malformed date. + assert _post_follow_up(client, user, parent_inspection_id=parent.id, + due_date='next tuesday').status_code == 400 + # Past date. + past = (date.today() - timedelta(days=1)).isoformat() + assert _post_follow_up(client, user, parent_inspection_id=parent.id, + due_date=past).status_code == 400 + + +def test_follow_up_allows_today(client): + """"Later today" is a legitimate plan; yesterday is not.""" + user, tmpl, fac = _seed('today', role='admin') + parent = _completed_inspection(user, tmpl, fac) + r = _post_follow_up(client, user, parent_inspection_id=parent.id, + due_date=date.today().isoformat()) + assert r.status_code == 201 + + +def test_follow_up_rejects_auditor(client): + """Auditor is read-only everywhere else; keep it that way here.""" + user, tmpl, fac = _seed('aud', role='auditor') + parent = _completed_inspection(user, tmpl, fac) + r = _post_follow_up(client, user, parent_inspection_id=parent.id, + due_date=(date.today() + timedelta(days=1)).isoformat()) + assert r.status_code == 403 + + +def test_inspector_cannot_follow_up_someone_elses_inspection(client): + from app import db + from app.models.user import User + + owner, tmpl, fac = _seed('own', role='inspector') + parent = _completed_inspection(owner, tmpl, fac) + + other = User(username='other_insp', full_name='Otto', role='inspector', + email='otto@example.com', active=True) + other.set_password('pw-correct1') + db.session.add(other) + db.session.commit() + + r = _post_follow_up(client, other, parent_inspection_id=parent.id, + due_date=(date.today() + timedelta(days=1)).isoformat()) + assert r.status_code == 403 + + +def test_follow_up_requires_auth(client): + user, tmpl, fac = _seed('noauth', role='admin') + parent = _completed_inspection(user, tmpl, fac) + r = client.post('/api/v1/scheduled-inspections/follow-up', + json={'parent_inspection_id': parent.id, + 'due_date': date.today().isoformat()}) + assert r.status_code == 401 + + +# ── The link actually propagates ───────────────────────────────────────────── + +def test_web_start_inherits_the_parent_link(client): + """This is the whole point: starting the schedule must produce a LINKED + re-inspection, not an ordinary one.""" + from app import db + from app.models.inspection import Inspection + from app.models.inspection_schedule import InspectionSchedule + + user, tmpl, fac = _seed('start', role='admin') + parent = _completed_inspection(user, tmpl, fac) + _post_follow_up(client, user, parent_inspection_id=parent.id, + due_date=date.today().isoformat()) + sched = InspectionSchedule.query.filter_by(parent_inspection_id=parent.id).one() + + client.post('/auth/login', data={'username': user.username, + 'password': 'pw-correct1'}, + follow_redirects=True) + resp = client.get(f'/inspection-schedules/{sched.id}/start', + follow_redirects=False) + assert resp.status_code == 302 + + run = (Inspection.query + .filter_by(inspection_schedule_id=sched.id, status='in_progress') + .one()) + assert run.parent_inspection_id == parent.id + + +def test_web_start_resumes_instead_of_duplicating(client): + from app.models.inspection import Inspection + from app.models.inspection_schedule import InspectionSchedule + + user, tmpl, fac = _seed('resume', role='admin') + parent = _completed_inspection(user, tmpl, fac) + _post_follow_up(client, user, parent_inspection_id=parent.id, + due_date=date.today().isoformat()) + sched = InspectionSchedule.query.filter_by(parent_inspection_id=parent.id).one() + + client.post('/auth/login', data={'username': user.username, + 'password': 'pw-correct1'}, + follow_redirects=True) + client.get(f'/inspection-schedules/{sched.id}/start') + client.get(f'/inspection-schedules/{sched.id}/start') + + runs = Inspection.query.filter_by(inspection_schedule_id=sched.id).all() + assert len(runs) == 1 + + +def test_cron_materialiser_inherits_the_parent_link(client): + from app import db + from app.models.inspection import Inspection + from app.models.inspection_schedule import InspectionSchedule + from app.utils.time_utils import now_eastern + + user, tmpl, fac = _seed('auto', role='admin') + parent = _completed_inspection(user, tmpl, fac) + + sched = InspectionSchedule( + name='Auto follow-up', template_id=tmpl.id, facility_id=fac.id, + inspector_id=user.id, frequency='once', mode='auto', active=True, + next_run_at=now_eastern() - timedelta(hours=1), + parent_inspection_id=parent.id, + ) + db.session.add(sched) + db.session.commit() + sid = sched.id + + resp = client.post('/inspection-schedules/run', data={'token': 'test-digest'}) + assert resp.get_json()['created'] == 1 + + run = Inspection.query.filter_by(inspection_schedule_id=sid).one() + assert run.parent_inspection_id == parent.id + # 'once' closes the schedule after its single run. + db.session.expire_all() + assert db.session.get(InspectionSchedule, sid).active is False + + +def test_api_create_infers_parent_from_the_schedule(client): + """An older iPad build submits the schedule id but no parent. Without the + inference the parent would stay flagged forever.""" + from app.models.inspection import Inspection + from app.models.inspection_schedule import InspectionSchedule + + user, tmpl, fac = _seed('infer', role='admin') + parent = _completed_inspection(user, tmpl, fac) + _post_follow_up(client, user, parent_inspection_id=parent.id, + due_date=date.today().isoformat()) + sched = InspectionSchedule.query.filter_by(parent_inspection_id=parent.id).one() + + resp = client.post('/api/v1/inspections', headers=_auth(user), json={ + 'template_id': tmpl.id, 'facility_id': fac.id, 'status': 'completed', + 'scheduled_inspection_id': sched.id, # no parent_inspection_id + 'form_data': {'f1': 5}, + }) + assert resp.status_code in (200, 201) + + new_id = resp.get_json()['data']['inspection_id'] + run = Inspection.query.get(new_id) + assert run.parent_inspection_id == parent.id + # And the whole point of the link: the parent's flag is cleared. + assert parent.follow_up_required is False + + +def test_api_explicit_parent_wins_over_the_schedule(client): + from app.models.inspection import Inspection + from app.models.inspection_schedule import InspectionSchedule + + user, tmpl, fac = _seed('explicit', role='admin') + parent_a = _completed_inspection(user, tmpl, fac) + parent_b = _completed_inspection(user, tmpl, fac) + _post_follow_up(client, user, parent_inspection_id=parent_a.id, + due_date=date.today().isoformat()) + sched = InspectionSchedule.query.filter_by(parent_inspection_id=parent_a.id).one() + + resp = client.post('/api/v1/inspections', headers=_auth(user), json={ + 'template_id': tmpl.id, 'facility_id': fac.id, 'status': 'completed', + 'scheduled_inspection_id': sched.id, + 'parent_inspection_id': parent_b.id, + 'form_data': {'f1': 5}, + }) + new_id = resp.get_json()['data']['inspection_id'] + assert Inspection.query.get(new_id).parent_inspection_id == parent_b.id + + +def test_ordinary_schedule_produces_no_parent_link(client): + """Every pre-phase48 row is NULL here and must stay that way.""" + from app import db + from app.models.inspection import Inspection + from app.models.inspection_schedule import InspectionSchedule + from app.utils.time_utils import now_eastern + + user, tmpl, fac = _seed('plain', role='admin') + sched = InspectionSchedule( + name='Plain weekly', template_id=tmpl.id, facility_id=fac.id, + inspector_id=user.id, frequency='weekly', mode='auto', active=True, + next_run_at=now_eastern() - timedelta(hours=1), + ) + db.session.add(sched) + db.session.commit() + assert sched.is_follow_up is False + + client.post('/inspection-schedules/run', data={'token': 'test-digest'}) + run = Inspection.query.filter_by(inspection_schedule_id=sched.id).one() + assert run.parent_inspection_id is None