From d9bf4be7099753d45afa35b9e49351e601bc636f Mon Sep 17 00:00:00 2001 From: NguyenND Date: Wed, 29 Jul 2026 21:49:19 -0400 Subject: [PATCH] Jul 29 - link mobile inspections to their schedule and fulfil it --- app/api/inspections.py | 132 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 127 insertions(+), 5 deletions(-) diff --git a/app/api/inspections.py b/app/api/inspections.py index e14ad8a..fa07ae2 100644 --- a/app/api/inspections.py +++ b/app/api/inspections.py @@ -96,6 +96,75 @@ def _parse_datetime(value): return None +def _schedule_id_from(data): + """Read the schedule link from a request body, accepting either key. + + The iPad sends `scheduled_inspection_id` (the single-tenant column name it + was built against); MT's column is `inspection_schedule_id`. Both are + accepted so shipped iPad builds keep working and a future build can migrate + to the MT name without a flag day. MT's own name wins if both are present. + """ + for key in ('inspection_schedule_id', 'scheduled_inspection_id'): + if data.get(key): + return data[key] + return None + + +def _resolve_schedule(schedule_id, user): + """Resolve a client-supplied schedule id to an InspectionSchedule, or None. + + The iPad sends this when the inspector taps Start on a scheduled row; + without it the inspection lands unlinked and the schedule is never fulfilled + (no link on the detail page, and the schedule stays due forever). + + NON-BLOCKING BY DESIGN. A bad link drops the link and logs a warning — it + never fails the submission. The app is offline-first, so a schedule can + legitimately be deleted or reassigned while a completed inspection sits in + the outbox for days; erroring here would retry-fail that inspection and + strand the inspector's work (and its photos) permanently. A missed fulfil is + recoverable from the web UI; a stranded submission is not. + + The ownership check still matters: accepting a foreign link would let one + inspector fulfil another's schedule. So the link is refused — but the + inspection itself is still accepted. + """ + from app.models.inspection_schedule import InspectionSchedule + + sched = db.session.get(InspectionSchedule, schedule_id) + if sched is None: + logger.warning('API INSPECTIONS | unknown schedule id=%s from user=%s ' + '— submitting unlinked', schedule_id, user.username) + return None + if user.role == 'inspector' and sched.inspector_id != user.id: + logger.warning('API INSPECTIONS | schedule id=%s not assigned to user=%s ' + '— submitting unlinked', schedule_id, user.username) + return None + return sched + + +def _fulfill_schedule(inspection): + """Roll the originating schedule forward. Caller commits. + + Mirrors routes/inspections.py exactly, including passing `_compute_next_run` + as `next_run_fn`. That argument is NOT optional in practice: MT's + `InspectionSchedule.fulfill()` leaves `next_run_at` untouched when it is + omitted, so the schedule would stay permanently due and keep firing overdue + reminders. The deferred import mirrors the web route and avoids a module-load + cycle between the api and routes packages. + """ + if not inspection.inspection_schedule_id: + return + from app.models.inspection_schedule import InspectionSchedule + from app.routes.inspection_schedules import _compute_next_run + + sched = db.session.get(InspectionSchedule, inspection.inspection_schedule_id) + if sched is None: + return + sched.fulfill(next_run_fn=_compute_next_run) + logger.info('API INSPECTIONS | schedule fulfilled | schedule=%s | inspection=%s ' + '| next_due=%s', sched.id, inspection.id, sched.next_run_at) + + def _media(key): """Absolute display URL for a storage key (presigned on R2, absolute-static on local). '' for falsy keys. Used for iPad image rendering.""" @@ -158,6 +227,13 @@ def _inspection_payload(inspection): 'follow_up_required': inspection.follow_up_required, 'follow_up_note': inspection.follow_up_note, 'parent_inspection_id': inspection.parent_inspection_id, + # ── Originating schedule (MT-14) ────────────────────────────────── + # Emitted under BOTH names: `inspection_schedule_id` is MT's column, + # `scheduled_inspection_id` is the name shipped iPad builds decode. + # They always carry the same value. Drop the legacy alias once every + # deployed client has moved to the MT name. + 'inspection_schedule_id': inspection.inspection_schedule_id, + 'scheduled_inspection_id': inspection.inspection_schedule_id, } @@ -279,9 +355,16 @@ def create_inspection(): "overall_score": 87.5, "inspection_date": "2026-05-01T14:30:00", "completed_at": "2026-05-01T15:00:00", - "mobile_local_id": "uuid-string" + "mobile_local_id": "uuid-string", + "inspection_schedule_id": 12 } + `inspection_schedule_id` links the inspection to the schedule it fulfils + (sent when the inspector taps Start on a scheduled row). + `scheduled_inspection_id` is accepted as an alias for shipped iPad builds. + An unresolvable or foreign id is dropped with a warning — it never fails the + submission. The schedule is rolled forward only when status is "completed". + Response 200 ------------ { "ok": true, "data": { "inspection_id": 42, "duplicate": false } } @@ -372,6 +455,16 @@ def create_inspection(): submit_latitude = None submit_longitude = None + # ── Originating schedule (MT-14) ────────────────────────────────────── + # Sent when the inspector taps Start on a scheduled row. Resolution is + # non-blocking: an unresolvable or foreign id drops the link and logs, but + # the inspection is still accepted (see _resolve_schedule). + inspection_schedule_id = None + _sched_id = _schedule_id_from(data) + if _sched_id: + _sched = _resolve_schedule(_sched_id, user) + inspection_schedule_id = _sched.id if _sched else None + inspection = Inspection( template_id = template_id, facility_id = facility_id, @@ -386,11 +479,19 @@ def create_inspection(): parent_inspection_id = parent_inspection_id, submit_latitude = submit_latitude, submit_longitude = submit_longitude, + inspection_schedule_id = inspection_schedule_id, ) db.session.add(inspection) db.session.flush() + # ── Fulfil the originating schedule ─────────────────────────────────── + # Staged into the same atomic commit as the inspection, mirroring the web + # route. Only on completion: an in_progress submission has not satisfied + # the occurrence, so rolling the schedule forward there would skip a cycle. + if status == 'completed': + _fulfill_schedule(inspection) + # ── Auto-clear follow-up flag on parent ─────────────────────────────── # When a completed re-inspection arrives that links to a parent, clear # follow_up_required on the parent automatically. This mirrors the web @@ -480,8 +581,13 @@ def update_inspection(inspection_id): "form_data": { ... }, "notes": "...", "overall_score": 91.0, - "completed_at": "2026-05-01T15:30:00" + "completed_at": "2026-05-01T15:30:00", + "inspection_schedule_id": 12 } + + `inspection_schedule_id` links the inspection to the schedule it fulfils. + `scheduled_inspection_id` is accepted as an alias for shipped iPad builds. + The schedule is rolled forward only on the draft → completed transition. """ user = g.api_user @@ -515,6 +621,15 @@ def update_inspection(inspection_id): prev_status = inspection.status + # Allow the link to be set/corrected on PATCH too — the iPad may create the + # inspection as a draft first and only attach the schedule on submit. + # Same non-blocking semantics as create: a bad id leaves the link untouched. + _sched_id = _schedule_id_from(data) + if _sched_id: + _sched = _resolve_schedule(_sched_id, user) + if _sched is not None: + inspection.inspection_schedule_id = _sched.id + if 'status' in data: inspection.status = data['status'] @@ -530,12 +645,19 @@ def update_inspection(inspection_id): elif data.get('status') == 'completed' and not inspection.completed_at: inspection.completed_at = now_eastern() - db.session.commit() - - # Notify when a draft transitions to completed — mirrors the POST handler. + # Computed BEFORE the commit so the schedule fulfil can be staged into the + # same transaction; reused after the commit for the notification below. transitioning_to_complete = ( data.get('status') == 'completed' and prev_status != 'completed' ) + # Fulfil on the draft → completed transition ONLY, so a later PATCH on an + # already-completed inspection cannot roll the schedule forward twice. + if transitioning_to_complete: + _fulfill_schedule(inspection) + + db.session.commit() + + # Notify when a draft transitions to completed — mirrors the POST handler. if transitioning_to_complete: score_val = inspection.overall_score score_display = f'{score_val:.1f}%' if score_val is not None else 'N/A'