Jul 29 - link mobile inspections to their schedule and fulfil it
This commit is contained in:
+127
-5
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user