853 lines
38 KiB
Python
853 lines
38 KiB
Python
"""
|
|
app/api/inspections.py
|
|
----------------------
|
|
Mobile API endpoints for submitting and retrieving inspections.
|
|
|
|
POST /api/v1/inspections
|
|
Creates a new inspection (offline sync submission).
|
|
Idempotent via mobile_local_id.
|
|
|
|
PATCH /api/v1/inspections/<inspection_id>
|
|
Updates an existing inspection (draft → completed).
|
|
|
|
GET /api/v1/inspections
|
|
Returns the authenticated inspector's own inspection history.
|
|
Supports ?limit=N&offset=N&facility_id=N&status=completed
|
|
"""
|
|
|
|
import logging
|
|
import json
|
|
import re
|
|
from datetime import datetime
|
|
|
|
from flask import Blueprint, request, g
|
|
from app import db
|
|
from app.models.inspection import Inspection, InspectionTemplate
|
|
from app.models.facility import Facility, Area
|
|
from app.api.errors import api_ok, api_error
|
|
from app.api.decorators import jwt_required
|
|
from app.tenancy.gates import feature_required, quota_soft_check
|
|
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE
|
|
from app.utils.notifications import notify_by_matrix
|
|
from app.utils.time_utils import now_eastern
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
bp = Blueprint('api_inspections', __name__)
|
|
|
|
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
|
|
'project_manager', 'auditor'}
|
|
|
|
|
|
def _merge_form_data(existing: dict, incoming: dict) -> dict:
|
|
"""Merge incoming form_data into existing, preserving file paths.
|
|
|
|
New non-empty values always win. The one exception: an empty string
|
|
coming from the client will NOT overwrite an existing server-side upload
|
|
path (any value that starts with 'uploads/'). This protects photo paths
|
|
stored during an earlier POST from being silently blanked when the iOS
|
|
sends a final PATCH whose form_data was rebuilt without re-including the
|
|
already-uploaded paths.
|
|
"""
|
|
merged = dict(existing)
|
|
for k, v in incoming.items():
|
|
existing_v = merged.get(k)
|
|
if (not v
|
|
and isinstance(existing_v, str)
|
|
and existing_v.startswith('uploads/')):
|
|
continue # keep the saved photo path
|
|
merged[k] = v
|
|
return merged
|
|
_UUID_RE = re.compile(
|
|
r'^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$',
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def _parse_datetime(value):
|
|
"""Parse an ISO 8601 datetime string; return a naive Eastern datetime.
|
|
|
|
Handles the formats produced by both the web forms and iOS
|
|
ISO8601DateFormatter():
|
|
2026-05-28T09:41:00 (web form, already Eastern-naive)
|
|
2026-05-28T09:41:00.000 (web form with ms)
|
|
2026-05-28T09:41:00Z (iOS ISO8601DateFormatter, UTC)
|
|
2026-05-28T09:41:00.000000Z (iOS with fractional seconds, UTC)
|
|
|
|
Values ending with 'Z' are treated as UTC and converted to Eastern.
|
|
Values without a timezone suffix are assumed to already be Eastern-local.
|
|
"""
|
|
if not value:
|
|
return None
|
|
from datetime import datetime, timezone as _tz
|
|
from app.utils.time_utils import EASTERN
|
|
|
|
is_utc = isinstance(value, str) and value.endswith('Z')
|
|
normalised = value.rstrip('Z') if isinstance(value, str) else value
|
|
for fmt in ('%Y-%m-%dT%H:%M:%S', '%Y-%m-%dT%H:%M:%S.%f', '%Y-%m-%d'):
|
|
try:
|
|
dt = datetime.strptime(normalised, fmt)
|
|
if is_utc:
|
|
dt = (dt.replace(tzinfo=_tz.utc)
|
|
.astimezone(EASTERN)
|
|
.replace(tzinfo=None))
|
|
return dt
|
|
except (ValueError, TypeError):
|
|
pass
|
|
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.is_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`. As of phase46 that argument is accepted and ignored: the
|
|
cadence maths moved onto `InspectionSchedule.advance_due_date()`, which owns
|
|
the recurrence columns and the end-date boundary. Before phase46 omitting it
|
|
silently left `next_run_at` untouched and the schedule stayed permanently
|
|
due; the call is kept as-is so this file needs no behavioural change. 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."""
|
|
from app.utils import storage
|
|
return storage.media_url(key, external=True) if key else ''
|
|
|
|
|
|
def _inspection_payload(inspection):
|
|
"""Serialize an Inspection to the dict returned in API responses."""
|
|
# Extract form responses from the notes JSON blob.
|
|
# Mobile submissions store form data as {"_form_data": {...}, "_inspector_notes": "..."}.
|
|
# Web submissions store form data in the form_data column directly.
|
|
form_data = {}
|
|
inspector_notes = ''
|
|
if inspection.notes:
|
|
try:
|
|
notes_obj = json.loads(inspection.notes)
|
|
if isinstance(notes_obj, dict):
|
|
form_data = notes_obj.get('_form_data', {}) or {}
|
|
inspector_notes = notes_obj.get('_inspector_notes', '') or ''
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
# Fallback: web-created inspections store responses in form_data column
|
|
if not form_data and inspection.form_data:
|
|
form_data = inspection.form_data if isinstance(inspection.form_data, dict) else {}
|
|
|
|
# Include the template's form_schema so the iPad can render history
|
|
# without needing a locally cached copy of the template.
|
|
form_schema = []
|
|
if inspection.template:
|
|
form_schema = inspection.template.get_form_schema()
|
|
|
|
return {
|
|
'id': inspection.id,
|
|
'template_id': inspection.template_id,
|
|
'template_name': inspection.template.name if inspection.template else '',
|
|
'facility_id': inspection.facility_id,
|
|
'facility_name': inspection.facility.name if inspection.facility else '',
|
|
'area_id': inspection.area_id,
|
|
'area_name': inspection.area.name if inspection.area else None,
|
|
'status': inspection.status,
|
|
'overall_score': float(inspection.overall_score) if inspection.overall_score is not None else None,
|
|
'inspection_date': inspection.inspection_date.isoformat()
|
|
if inspection.inspection_date else None,
|
|
'completed_at': inspection.completed_at.isoformat()
|
|
if inspection.completed_at else None,
|
|
'mobile_local_id': inspection.mobile_local_id,
|
|
'form_data': form_data,
|
|
# Absolute display URLs for image form fields (presigned on R2,
|
|
# absolute-static on local): {field_id: url}. The iPad prefers this
|
|
# over building ServerConfig + /static/ + value.
|
|
'form_media': {
|
|
fid: _media(v)
|
|
for fid, v in (form_data or {}).items()
|
|
if isinstance(v, str) and v.startswith('uploads/')
|
|
},
|
|
'form_schema': form_schema,
|
|
'inspector_notes': inspector_notes,
|
|
# ── Follow-up / re-inspection fields ──────────────────────────────
|
|
'follow_up_required': inspection.follow_up_required,
|
|
# phase56 — who is to perform the follow-up. NULL means the
|
|
# inspection's own inspector, which is what it always meant.
|
|
'follow_up_assigned_to': inspection.follow_up_assigned_to,
|
|
'follow_up_assigned_to_name': (inspection.follow_up_assignee.display_name
|
|
if inspection.follow_up_assignee else None),
|
|
'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,
|
|
}
|
|
|
|
|
|
# ── Inspection History ────────────────────────────────────────────────────────
|
|
|
|
@bp.route('/inspections', methods=['GET'])
|
|
@jwt_required
|
|
def list_inspections():
|
|
"""
|
|
Return the authenticated user's inspection history.
|
|
|
|
Inspectors see only their own inspections.
|
|
Admins/directors/project_managers see all inspections.
|
|
|
|
Query parameters
|
|
----------------
|
|
limit int default 50, max 200
|
|
offset int default 0
|
|
facility_id int filter by facility
|
|
status str filter by status (completed, in_progress, flagged)
|
|
follow_up_required
|
|
bool 'true'/'1' — only inspections awaiting a re-inspection,
|
|
scoped to the caller's own follow-ups (see below)
|
|
from_date str ISO date (YYYY-MM-DD) — include inspections on/after this date
|
|
to_date str ISO date (YYYY-MM-DD) — include inspections on/before this date
|
|
|
|
Response 200
|
|
------------
|
|
{
|
|
"ok": true,
|
|
"data": {
|
|
"inspections": [...],
|
|
"total": 42,
|
|
"limit": 50,
|
|
"offset": 0
|
|
}
|
|
}
|
|
"""
|
|
user = g.api_user
|
|
|
|
if user.role not in _ALLOWED_ROLES:
|
|
return api_error('Access denied', 403)
|
|
|
|
limit = min(request.args.get('limit', 50, type=int) or 50, 200)
|
|
offset = max(request.args.get('offset', 0, type=int) or 0, 0)
|
|
|
|
query = Inspection.query
|
|
|
|
# Inspectors only see their own inspections.
|
|
#
|
|
# EXCEPT when asking for follow-up requests: a follow-up can now be handed
|
|
# to a different inspector (phase56), and that request lives on an
|
|
# inspection somebody ELSE performed. Applying this filter first would hide
|
|
# exactly the rows the assignee needs, so it is deferred to the follow-up
|
|
# block below, which applies ownership instead of authorship.
|
|
wants_follow_ups = request.args.get('follow_up_required', '').lower() in ('true', '1')
|
|
if user.is_inspector and not wants_follow_ups:
|
|
query = query.filter(Inspection.inspector_id == user.id)
|
|
|
|
# Optional filters
|
|
facility_id = request.args.get('facility_id', type=int)
|
|
if facility_id:
|
|
query = query.filter(Inspection.facility_id == facility_id)
|
|
|
|
status = request.args.get('status')
|
|
if status:
|
|
query = query.filter(Inspection.status == status)
|
|
|
|
if wants_follow_ups:
|
|
# MT had no follow_up_required filter at all, so the iPad's Follow-up
|
|
# Requests screen — which calls ?follow_up_required=true — received the
|
|
# inspector's ENTIRE history and presented it as outstanding requests.
|
|
#
|
|
# "Follow-up" must mean exactly what it means everywhere on the web
|
|
# (inspections.index / reports status_filter == 'follow_up'): flagged,
|
|
# completed, and not yet answered by a linked re-inspection.
|
|
#
|
|
# The ~follow_ups.any() clause is the one that matters. The web execute
|
|
# route never clears follow_up_required on the parent — it only stops
|
|
# listing it once a child exists — so filtering on the flag alone would
|
|
# return follow-ups that were already satisfied on the web, forever.
|
|
# On the iPad those rows are undismissable: pull_follow_up_requests()
|
|
# keeps receiving them and update(from:) resets fulfilledLocally, so the
|
|
# FOLLOW-UP REQUESTED card would never clear. (The mobile POST path does
|
|
# clear the parent flag, so only web-completed re-inspections stick.)
|
|
query = query.filter(
|
|
Inspection.follow_up_required.is_(True),
|
|
Inspection.status == 'completed',
|
|
).filter(~Inspection.follow_ups.any())
|
|
|
|
# Ownership, not authorship (phase56). Mirrors
|
|
# Inspection.follow_up_owner: an assigned follow-up belongs to the
|
|
# assignee ALONE, an unassigned one to the inspection's own inspector.
|
|
#
|
|
# The two arms are mutually exclusive on purpose. Without the second
|
|
# arm's `is_(None)` an inspector would keep seeing a follow-up that had
|
|
# been handed to someone else, and two people would turn up to do it.
|
|
if user.is_inspector:
|
|
query = query.filter(Inspection.follow_up_owned_by(user.id))
|
|
|
|
from_date_str = request.args.get('from_date')
|
|
if from_date_str:
|
|
try:
|
|
from_dt = datetime.strptime(from_date_str, '%Y-%m-%d').date()
|
|
query = query.filter(Inspection.inspection_date >= from_dt)
|
|
except ValueError:
|
|
pass # malformed date — ignore silently
|
|
|
|
to_date_str = request.args.get('to_date')
|
|
if to_date_str:
|
|
try:
|
|
to_dt = datetime.strptime(to_date_str, '%Y-%m-%d').date()
|
|
query = query.filter(Inspection.inspection_date <= to_dt)
|
|
except ValueError:
|
|
pass # malformed date — ignore silently
|
|
|
|
total = query.count()
|
|
inspections = (
|
|
query
|
|
.order_by(Inspection.inspection_date.desc())
|
|
.offset(offset)
|
|
.limit(limit)
|
|
.all()
|
|
)
|
|
|
|
payload = [_inspection_payload(i) for i in inspections]
|
|
|
|
logger.info('API INSPECTIONS | list | user=%s | count=%d | total=%d',
|
|
user.username, len(payload), total)
|
|
|
|
return api_ok({
|
|
'inspections': payload,
|
|
'total': total,
|
|
'limit': limit,
|
|
'offset': offset,
|
|
})
|
|
|
|
|
|
# ── Create Inspection ─────────────────────────────────────────────────────────
|
|
|
|
@bp.route('/inspections', methods=['POST'])
|
|
@jwt_required
|
|
@feature_required('mobile_api')
|
|
@quota_soft_check('inspections')
|
|
def create_inspection():
|
|
"""
|
|
Create a new inspection submitted from the iPad app.
|
|
|
|
Idempotency: if mobile_local_id is provided and an inspection with that
|
|
ID already exists, the existing record is returned without duplication.
|
|
|
|
Request JSON
|
|
------------
|
|
{
|
|
"template_id": 3,
|
|
"facility_id": 7,
|
|
"area_id": 12,
|
|
"status": "completed",
|
|
"form_data": { ... },
|
|
"notes": "...",
|
|
"overall_score": 87.5,
|
|
"inspection_date": "2026-05-01T14:30:00",
|
|
"completed_at": "2026-05-01T15:00:00",
|
|
"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 } }
|
|
"""
|
|
user = g.api_user
|
|
|
|
if user.role not in _ALLOWED_ROLES:
|
|
return api_error('Access denied', 403)
|
|
|
|
data = request.get_json(silent=True) or {}
|
|
|
|
# ── Idempotency check ─────────────────────────────────────────────────
|
|
mobile_local_id = data.get('mobile_local_id')
|
|
if mobile_local_id:
|
|
if not _UUID_RE.match(str(mobile_local_id)):
|
|
return api_error('mobile_local_id must be a valid UUID', 400)
|
|
existing = Inspection.query.filter_by(mobile_local_id=mobile_local_id).first()
|
|
if existing:
|
|
logger.info('API INSPECTIONS | duplicate | local_id=%s | inspection_id=%d | user=%s',
|
|
mobile_local_id, existing.id, user.username)
|
|
return api_ok({'inspection_id': existing.id, 'duplicate': True})
|
|
|
|
# ── Validate required fields ──────────────────────────────────────────
|
|
template_id = data.get('template_id')
|
|
facility_id = data.get('facility_id')
|
|
|
|
if not template_id or not facility_id:
|
|
return api_error('template_id and facility_id are required', 400)
|
|
|
|
template = db.session.get(InspectionTemplate, template_id)
|
|
if template is None:
|
|
return api_error('Template not found', 404)
|
|
|
|
facility = db.session.get(Facility, facility_id)
|
|
if facility is None or not facility.active:
|
|
return api_error('Facility not found', 404)
|
|
|
|
area_id = data.get('area_id')
|
|
if area_id:
|
|
area = db.session.get(Area, area_id)
|
|
if area is None or area.facility_id != facility_id:
|
|
return api_error('Area not found or does not belong to the facility', 400)
|
|
|
|
status = data.get('status', 'completed')
|
|
if status not in ('in_progress', 'completed'):
|
|
return api_error('status must be "in_progress" or "completed"', 400)
|
|
|
|
# ── Optional parent link (re-inspection) ──────────────────────────────
|
|
parent_inspection_id = data.get('parent_inspection_id')
|
|
if parent_inspection_id:
|
|
parent = db.session.get(Inspection, parent_inspection_id)
|
|
if parent is None:
|
|
return api_error('Parent inspection not found', 404)
|
|
|
|
# ── Score calculation ─────────────────────────────────────────────────
|
|
overall_score = data.get('overall_score')
|
|
if overall_score is None and status == 'completed':
|
|
form_data = data.get('form_data') or {}
|
|
form_fields = template.get_form_schema()
|
|
overall_score = _compute_score(form_fields, form_data)
|
|
|
|
# ── Build inspection record ───────────────────────────────────────────
|
|
inspection_date = _parse_datetime(data.get('inspection_date')) or now_eastern()
|
|
completed_at = _parse_datetime(data.get('completed_at'))
|
|
|
|
form_data = data.get('form_data') or {}
|
|
notes_payload = {}
|
|
if data.get('notes'):
|
|
notes_payload['_inspector_notes'] = data['notes']
|
|
notes_payload['_form_data'] = form_data
|
|
|
|
# If the client sent completed_at but it failed to parse (e.g. unrecognised
|
|
# format), fall back to now rather than storing NULL. This mirrors the
|
|
# PATCH handler's behaviour.
|
|
if completed_at is None and status == 'completed':
|
|
completed_at = now_eastern()
|
|
|
|
# ── GPS (mobile submission) ───────────────────────────────────────────
|
|
# The iPad sends submit_latitude / submit_longitude when CoreLocation
|
|
# granted permission and a fix was obtained before the inspector confirmed
|
|
# submission. Both fields are nullable — absence is silently ignored.
|
|
_lat = data.get('submit_latitude')
|
|
_lng = data.get('submit_longitude')
|
|
try:
|
|
submit_latitude = float(_lat) if _lat is not None else None
|
|
submit_longitude = float(_lng) if _lng is not None else None
|
|
except (ValueError, TypeError):
|
|
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
|
|
|
|
# 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,
|
|
area_id = area_id,
|
|
inspector_id = user.id,
|
|
inspection_date = inspection_date,
|
|
overall_score = overall_score,
|
|
status = status,
|
|
notes = json.dumps(notes_payload),
|
|
completed_at = completed_at if status == 'completed' else None,
|
|
mobile_local_id = mobile_local_id,
|
|
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
|
|
# list view's implicit logic (which hides the badge when follow_ups.any())
|
|
# and ensures the History API response reflects the resolved state.
|
|
# Capture parent label strings before commit while ORM objects are loaded.
|
|
# log_action() for the parent update must fire AFTER db.session.commit() to
|
|
# avoid audit.py's internal commit() persisting the parent flag change before
|
|
# the new inspection row is committed — a partial state that would be incorrect
|
|
# if the main commit subsequently failed.
|
|
_parent_log_args = None
|
|
if parent_inspection_id and status == 'completed':
|
|
parent_insp = db.session.get(Inspection, parent_inspection_id)
|
|
if parent_insp and parent_insp.follow_up_required:
|
|
parent_insp.follow_up_required = False
|
|
logger.info(
|
|
'API INSPECTIONS | follow_up cleared | parent_id=%d | '
|
|
'by_inspection_id=%d | user=%s',
|
|
parent_inspection_id, inspection.id, user.username,
|
|
)
|
|
# Snapshot label strings now — ORM objects may be expired after commit
|
|
_parent_log_args = (
|
|
parent_inspection_id,
|
|
f'{parent_insp.template.name} @ {parent_insp.facility.name}',
|
|
f'follow_up_required=False (cleared by re-inspection '
|
|
f'#{inspection.id} via mobile API)',
|
|
)
|
|
|
|
# ── Notifications ─────────────────────────────────────────────────────
|
|
if status == 'completed':
|
|
score_display = f'{overall_score:.1f}%' if overall_score is not None else 'N/A'
|
|
try:
|
|
from flask import url_for
|
|
inspection_link = url_for('inspections.view',
|
|
inspection_id=inspection.id, _external=False)
|
|
except RuntimeError:
|
|
inspection_link = f'/inspections/{inspection.id}'
|
|
|
|
notify_by_matrix(
|
|
event_type = 'inspection_completed',
|
|
title = f'Inspection #{inspection.id} Completed (Mobile)',
|
|
body = (
|
|
f'{user.display_name} completed an inspection at '
|
|
f'{facility.name} using the "{template.name}" template. '
|
|
f'Overall score: {score_display}.'
|
|
),
|
|
link = inspection_link,
|
|
inspection_id = inspection.id,
|
|
facility_id = facility_id,
|
|
)
|
|
|
|
db.session.commit()
|
|
|
|
# ── Post-commit audit logging ──────────────────────────────────────────
|
|
# All log_action() calls must come AFTER db.session.commit() because
|
|
# audit.py calls db.session.commit() internally. Calling it before the
|
|
# main commit would persist the audit row (and any dirty ORM state) before
|
|
# the primary transaction completes.
|
|
if _parent_log_args:
|
|
log_action(ACTION_UPDATE, 'Inspection', *_parent_log_args)
|
|
|
|
log_action(ACTION_CREATE, 'Inspection', inspection.id,
|
|
f'{template.name} @ {facility.name}',
|
|
f'source=mobile; status={status}; score={overall_score}; '
|
|
f'local_id={mobile_local_id}; parent_id={parent_inspection_id}')
|
|
|
|
logger.info('API INSPECTIONS | created | inspection_id=%d | facility=%s | '
|
|
'template=%s | status=%s | score=%s | user=%s',
|
|
inspection.id, facility.name, template.name,
|
|
status, overall_score, user.username)
|
|
|
|
return api_ok({'inspection_id': inspection.id, 'duplicate': False})
|
|
|
|
|
|
# ── Update Inspection ─────────────────────────────────────────────────────────
|
|
|
|
@bp.route('/inspections/<int:inspection_id>', methods=['PATCH'])
|
|
@jwt_required
|
|
def update_inspection(inspection_id):
|
|
"""
|
|
Update an existing inspection (e.g. draft → completed).
|
|
|
|
Request JSON (all fields optional)
|
|
------------
|
|
{
|
|
"status": "completed",
|
|
"form_data": { ... },
|
|
"notes": "...",
|
|
"overall_score": 91.0,
|
|
"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
|
|
|
|
if user.role not in _ALLOWED_ROLES:
|
|
return api_error('Access denied', 403)
|
|
|
|
inspection = db.session.get(Inspection, inspection_id)
|
|
if inspection is None:
|
|
return api_error('Inspection not found', 404)
|
|
|
|
if user.is_inspector and inspection.inspector_id != user.id:
|
|
return api_error('Access denied', 403)
|
|
|
|
data = request.get_json(silent=True) or {}
|
|
|
|
if 'form_data' in data or 'notes' in data:
|
|
existing_notes = {}
|
|
if inspection.notes:
|
|
try:
|
|
existing_notes = json.loads(inspection.notes)
|
|
except (json.JSONDecodeError, TypeError):
|
|
existing_notes = {}
|
|
if 'form_data' in data:
|
|
existing_notes['_form_data'] = _merge_form_data(
|
|
existing_notes.get('_form_data') or {},
|
|
data['form_data'] or {},
|
|
)
|
|
if 'notes' in data:
|
|
existing_notes['_inspector_notes'] = data['notes']
|
|
inspection.notes = json.dumps(existing_notes)
|
|
|
|
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
|
|
# phase48 parity with the POST path: a schedule created by
|
|
# "Schedule Follow-up" knows which inspection it answers, so a draft
|
|
# that only gets its schedule attached here still becomes a properly
|
|
# linked re-inspection. Never overrides an explicit parent.
|
|
if not inspection.parent_inspection_id and _sched.parent_inspection_id:
|
|
inspection.parent_inspection_id = _sched.parent_inspection_id
|
|
logger.info('API INSPECTIONS | parent inherited from schedule on '
|
|
'PATCH | schedule=%s | parent=%s | user=%s',
|
|
_sched.id, _sched.parent_inspection_id, user.username)
|
|
|
|
# An explicitly supplied parent still wins, and can be set on the draft
|
|
# before submit — mirrors the POST handler's field list.
|
|
if 'parent_inspection_id' in data:
|
|
_pid = data.get('parent_inspection_id')
|
|
if isinstance(_pid, int) and db.session.get(Inspection, _pid) is not None:
|
|
inspection.parent_inspection_id = _pid
|
|
|
|
if 'status' in data:
|
|
inspection.status = data['status']
|
|
|
|
if 'overall_score' in data:
|
|
inspection.overall_score = data['overall_score']
|
|
elif data.get('status') == 'completed' and inspection.overall_score is None:
|
|
form_data = data.get('form_data') or {}
|
|
form_fields = inspection.template.get_form_schema()
|
|
inspection.overall_score = _compute_score(form_fields, form_data)
|
|
|
|
if 'completed_at' in data:
|
|
inspection.completed_at = _parse_datetime(data['completed_at']) or now_eastern()
|
|
elif data.get('status') == 'completed' and not inspection.completed_at:
|
|
inspection.completed_at = now_eastern()
|
|
|
|
# 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)
|
|
|
|
# ── Auto-clear follow-up flag on parent ───────────────────────────────
|
|
# Mirrors the POST handler. This was previously MISSING here, so an iPad
|
|
# that created a follow-up as a draft and submitted it via PATCH left the
|
|
# parent flagged forever — the re-inspection happened, but the parent still
|
|
# showed "Follow-up Inspection Required" and stayed in every manager's
|
|
# outstanding list. phase48 made that a normal path, since a schedule-started
|
|
# follow-up is a draft first.
|
|
#
|
|
# Same commit-ordering rule as the POST handler: log_action() must fire AFTER
|
|
# db.session.commit(), because audit.py commits internally and would
|
|
# otherwise persist the parent's flag change before this inspection's own
|
|
# changes are committed — a partial state if the main commit then failed.
|
|
_parent_log_args = None
|
|
if transitioning_to_complete and inspection.parent_inspection_id:
|
|
parent_insp = db.session.get(Inspection, inspection.parent_inspection_id)
|
|
if parent_insp and parent_insp.follow_up_required:
|
|
parent_insp.follow_up_required = False
|
|
logger.info(
|
|
'API INSPECTIONS | follow_up cleared on PATCH | parent_id=%s | '
|
|
'by_inspection_id=%s | user=%s',
|
|
parent_insp.id, inspection.id, user.username,
|
|
)
|
|
# Snapshot label strings now — ORM objects may be expired after commit.
|
|
_parent_log_args = (
|
|
parent_insp.id,
|
|
f'{parent_insp.template.name} @ {parent_insp.facility.name}',
|
|
f'follow_up_required=False (cleared by re-inspection '
|
|
f'#{inspection.id} via mobile API)',
|
|
)
|
|
|
|
db.session.commit()
|
|
|
|
if _parent_log_args:
|
|
log_action(ACTION_UPDATE, 'Inspection', *_parent_log_args)
|
|
|
|
# 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'
|
|
template_name = inspection.template.name if inspection.template else 'Unknown'
|
|
facility_name = inspection.facility.name if inspection.facility else 'Unknown'
|
|
try:
|
|
from flask import url_for
|
|
inspection_link = url_for('inspections.view',
|
|
inspection_id=inspection.id, _external=False)
|
|
except RuntimeError:
|
|
inspection_link = f'/inspections/{inspection.id}'
|
|
notify_by_matrix(
|
|
event_type = 'inspection_completed',
|
|
title = f'Inspection #{inspection.id} Completed (Mobile)',
|
|
body = (
|
|
f'{user.display_name} completed an inspection at '
|
|
f'{facility_name} using the "{template_name}" template. '
|
|
f'Overall score: {score_display}.'
|
|
),
|
|
link = inspection_link,
|
|
inspection_id = inspection.id,
|
|
facility_id = inspection.facility_id,
|
|
)
|
|
db.session.commit() # persist notification rows added by notify()
|
|
|
|
log_action(ACTION_UPDATE, 'Inspection', inspection.id,
|
|
f'{inspection.template.name} @ {inspection.facility.name}',
|
|
f'source=mobile; fields_updated={list(data.keys())}')
|
|
|
|
logger.info('API INSPECTIONS | updated | inspection_id=%d | user=%s | fields=%s',
|
|
inspection_id, user.username, list(data.keys()))
|
|
|
|
return api_ok({'inspection_id': inspection_id})
|
|
|
|
|
|
# ── Score helper ──────────────────────────────────────────────────────────────
|
|
|
|
def _compute_score(form_fields, responses):
|
|
"""
|
|
Mirror of routes/inspections.py::_compute_score_from_form().
|
|
Rating value 0 = unanswered — excluded from calculation.
|
|
"""
|
|
scoreable = [f for f in form_fields
|
|
if f.get('type') in ('rating', 'checkbox', 'radio', 'pass_fail')]
|
|
if not scoreable:
|
|
return None
|
|
|
|
total, earned = 0, 0
|
|
for field in scoreable:
|
|
fid = field.get('id')
|
|
val = responses.get(str(fid), responses.get(fid, ''))
|
|
ftype = field.get('type')
|
|
|
|
if ftype == 'rating':
|
|
try:
|
|
v = int(val)
|
|
if v == 0:
|
|
continue
|
|
earned += v
|
|
total += 5
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
elif ftype == 'checkbox':
|
|
total += 1
|
|
if val == 'true':
|
|
earned += 1
|
|
|
|
elif ftype == 'radio':
|
|
total += 1
|
|
if str(val).lower() in ('pass', 'yes', 'ok', 'good', 'acceptable', 'compliant'):
|
|
earned += 1
|
|
|
|
elif ftype == 'pass_fail':
|
|
if not val:
|
|
continue
|
|
total += 1
|
|
if str(val).lower() in ('pass', 'yes', 'ok', 'good', 'acceptable', 'compliant'):
|
|
earned += 1
|
|
|
|
return round((earned / total) * 100, 2) if total else None |