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
|
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'
|
||||||
|
|||||||
Reference in New Issue
Block a user