05/02 Phase C
This commit is contained in:
+119
-31
@@ -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 0–100 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)
|
||||||
|
|||||||
Reference in New Issue
Block a user