Jul 29 - link mobile inspections to their schedule and fulfil it

This commit is contained in:
2026-07-29 21:49:19 -04:00
parent 60a9af106a
commit d9bf4be709
+127 -5
View File
@@ -96,6 +96,75 @@ def _parse_datetime(value):
return None 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): def _media(key):
"""Absolute display URL for a storage key (presigned on R2, absolute-static """Absolute display URL for a storage key (presigned on R2, absolute-static
on local). '' for falsy keys. Used for iPad image rendering.""" 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_required': inspection.follow_up_required,
'follow_up_note': inspection.follow_up_note, 'follow_up_note': inspection.follow_up_note,
'parent_inspection_id': inspection.parent_inspection_id, '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, "overall_score": 87.5,
"inspection_date": "2026-05-01T14:30:00", "inspection_date": "2026-05-01T14:30:00",
"completed_at": "2026-05-01T15:00: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 Response 200
------------ ------------
{ "ok": true, "data": { "inspection_id": 42, "duplicate": false } } { "ok": true, "data": { "inspection_id": 42, "duplicate": false } }
@@ -372,6 +455,16 @@ def create_inspection():
submit_latitude = None submit_latitude = None
submit_longitude = 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( inspection = Inspection(
template_id = template_id, template_id = template_id,
facility_id = facility_id, facility_id = facility_id,
@@ -386,11 +479,19 @@ def create_inspection():
parent_inspection_id = parent_inspection_id, parent_inspection_id = parent_inspection_id,
submit_latitude = submit_latitude, submit_latitude = submit_latitude,
submit_longitude = submit_longitude, submit_longitude = submit_longitude,
inspection_schedule_id = inspection_schedule_id,
) )
db.session.add(inspection) db.session.add(inspection)
db.session.flush() 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 ─────────────────────────────── # ── Auto-clear follow-up flag on parent ───────────────────────────────
# When a completed re-inspection arrives that links to a parent, clear # When a completed re-inspection arrives that links to a parent, clear
# follow_up_required on the parent automatically. This mirrors the web # follow_up_required on the parent automatically. This mirrors the web
@@ -480,8 +581,13 @@ def update_inspection(inspection_id):
"form_data": { ... }, "form_data": { ... },
"notes": "...", "notes": "...",
"overall_score": 91.0, "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 user = g.api_user
@@ -515,6 +621,15 @@ def update_inspection(inspection_id):
prev_status = inspection.status 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: if 'status' in data:
inspection.status = data['status'] inspection.status = data['status']
@@ -530,12 +645,19 @@ def update_inspection(inspection_id):
elif data.get('status') == 'completed' and not inspection.completed_at: elif data.get('status') == 'completed' and not inspection.completed_at:
inspection.completed_at = now_eastern() inspection.completed_at = now_eastern()
db.session.commit() # Computed BEFORE the commit so the schedule fulfil can be staged into the
# same transaction; reused after the commit for the notification below.
# Notify when a draft transitions to completed — mirrors the POST handler.
transitioning_to_complete = ( transitioning_to_complete = (
data.get('status') == 'completed' and prev_status != 'completed' 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: if transitioning_to_complete:
score_val = inspection.overall_score score_val = inspection.overall_score
score_display = f'{score_val:.1f}%' if score_val is not None else 'N/A' score_display = f'{score_val:.1f}%' if score_val is not None else 'N/A'