05/02 Phase C

This commit is contained in:
Nguyen Ngo
2026-05-03 07:54:28 -04:00
parent fcbfd64c68
commit 18752cc9ab
+119 -31
View File
@@ -1,21 +1,24 @@
""" """
app/api/inspections.py app/api/inspections.py
---------------------- ----------------------
Mobile API endpoints for submitting inspections from the iPad app. Mobile API endpoints for submitting and retrieving inspections.
POST /api/v1/inspections POST /api/v1/inspections
Creates a new inspection record (status: in_progress or completed). Creates a new inspection (offline sync submission).
Accepts a mobile_local_id for idempotency — if an inspection with the Idempotent via mobile_local_id.
same local_id already exists, returns the existing record without
creating a duplicate.
PATCH /api/v1/inspections/<inspection_id> PATCH /api/v1/inspections/<inspection_id>
Updates an existing inspection (e.g. draft → completed). 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 logging
import json
from flask import Blueprint, request, g, current_app from flask import Blueprint, request, g
from app import db from app import db
from app.models.inspection import Inspection, InspectionTemplate from app.models.inspection import Inspection, InspectionTemplate
from app.models.facility import Facility, Area from app.models.facility import Facility, Area
@@ -45,6 +48,101 @@ def _parse_datetime(value):
return None return None
def _inspection_payload(inspection):
"""Serialize an Inspection to the dict returned in API responses."""
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': inspection.overall_score,
'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,
}
# ── 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)
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(int(request.args.get('limit', 50)), 200)
offset = max(int(request.args.get('offset', 0)), 0)
query = Inspection.query
# Inspectors only see their own inspections
if user.role == 'inspector':
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)
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 ───────────────────────────────────────────────────────── # ── Create Inspection ─────────────────────────────────────────────────────────
@bp.route('/inspections', methods=['POST']) @bp.route('/inspections', methods=['POST'])
@@ -55,22 +153,20 @@ def create_inspection():
Idempotency: if mobile_local_id is provided and an inspection with that Idempotency: if mobile_local_id is provided and an inspection with that
ID already exists, the existing record is returned without duplication. ID already exists, the existing record is returned without duplication.
This protects against double-submission when the network fails after the
server commits but before the device receives the response.
Request JSON Request JSON
------------ ------------
{ {
"template_id": 3, "template_id": 3,
"facility_id": 7, "facility_id": 7,
"area_id": 12, // optional "area_id": 12,
"status": "completed", // "in_progress" | "completed" "status": "completed",
"form_data": { ... }, // { field_id: value } "form_data": { ... },
"notes": "...", // optional inspector notes "notes": "...",
"overall_score": 87.5, // optional — computed server-side if omitted "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", // required when status=completed "completed_at": "2026-05-01T15:00:00",
"mobile_local_id": "uuid-string" // idempotency key "mobile_local_id": "uuid-string"
} }
Response 200 Response 200
@@ -129,7 +225,6 @@ def create_inspection():
inspection_date = _parse_datetime(data.get('inspection_date')) or now_eastern() inspection_date = _parse_datetime(data.get('inspection_date')) or now_eastern()
completed_at = _parse_datetime(data.get('completed_at')) completed_at = _parse_datetime(data.get('completed_at'))
import json
form_data = data.get('form_data') or {} form_data = data.get('form_data') or {}
notes_payload = {} notes_payload = {}
if data.get('notes'): if data.get('notes'):
@@ -150,9 +245,9 @@ def create_inspection():
) )
db.session.add(inspection) db.session.add(inspection)
db.session.flush() # get inspection.id before commit db.session.flush()
# ── Notifications (completed inspections only) ───────────────────────── # ── Notifications ─────────────────────────────────────────────────────
if status == 'completed': if status == 'completed':
score_display = f'{overall_score:.1f}%' if overall_score is not None else 'N/A' score_display = f'{overall_score:.1f}%' if overall_score is not None else 'N/A'
try: try:
@@ -197,9 +292,9 @@ def create_inspection():
@jwt_required @jwt_required
def update_inspection(inspection_id): def update_inspection(inspection_id):
""" """
Update an existing inspection e.g. promoting a draft to completed. Update an existing inspection (e.g. draft completed).
Request JSON (all fields optional — only provided fields are updated) Request JSON (all fields optional)
------------ ------------
{ {
"status": "completed", "status": "completed",
@@ -208,10 +303,6 @@ def update_inspection(inspection_id):
"overall_score": 91.0, "overall_score": 91.0,
"completed_at": "2026-05-01T15:30:00" "completed_at": "2026-05-01T15:30:00"
} }
Response 200
------------
{ "ok": true, "data": { "inspection_id": 42 } }
""" """
user = g.api_user user = g.api_user
@@ -222,13 +313,11 @@ def update_inspection(inspection_id):
if inspection is None: if inspection is None:
return api_error('Inspection not found', 404) return api_error('Inspection not found', 404)
# Inspectors can only update their own inspections
if user.role == 'inspector' and inspection.inspector_id != user.id: if user.role == 'inspector' and inspection.inspector_id != user.id:
return api_error('Access denied', 403) return api_error('Access denied', 403)
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
import json
if 'form_data' in data or 'notes' in data: if 'form_data' in data or 'notes' in data:
existing_notes = {} existing_notes = {}
if inspection.notes: if inspection.notes:
@@ -269,13 +358,12 @@ def update_inspection(inspection_id):
return api_ok({'inspection_id': inspection_id}) return api_ok({'inspection_id': inspection_id})
# ── Score helper (mirrors Python _compute_score_from_form exactly) ──────────── # ── Score helper ──────────────────────────────────────────────────────────────
def _compute_score(form_fields, responses): def _compute_score(form_fields, responses):
""" """
Mirror of routes/inspections.py::_compute_score_from_form(). Mirror of routes/inspections.py::_compute_score_from_form().
Returns float 0100 or None if no scoreable fields exist. Rating value 0 = unanswered — excluded from calculation.
Rating value 0 means unanswered — excluded from calculation.
""" """
scoreable = [f for f in form_fields scoreable = [f for f in form_fields
if f.get('type') in ('rating', 'checkbox', 'radio', 'pass_fail')] if f.get('type') in ('rating', 'checkbox', 'radio', 'pass_fail')]
@@ -286,8 +374,8 @@ def _compute_score(form_fields, responses):
for field in scoreable: for field in scoreable:
fid = field.get('id') fid = field.get('id')
val = responses.get(str(fid), responses.get(fid, '')) val = responses.get(str(fid), responses.get(fid, ''))
ftype = field.get('type') ftype = field.get('type')
if ftype == 'rating': if ftype == 'rating':
try: try:
v = int(val) v = int(val)