diff --git a/app/api/inspections.py b/app/api/inspections.py index fffde6b..a07096a 100644 --- a/app/api/inspections.py +++ b/app/api/inspections.py @@ -1,21 +1,24 @@ """ 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 - Creates a new inspection record (status: in_progress or completed). - Accepts a mobile_local_id for idempotency — if an inspection with the - same local_id already exists, returns the existing record without - creating a duplicate. +POST /api/v1/inspections + Creates a new inspection (offline sync submission). + Idempotent via mobile_local_id. PATCH /api/v1/inspections/ - 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 json -from flask import Blueprint, request, g, current_app +from flask import Blueprint, request, g from app import db from app.models.inspection import Inspection, InspectionTemplate from app.models.facility import Facility, Area @@ -45,6 +48,101 @@ def _parse_datetime(value): 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 ───────────────────────────────────────────────────────── @bp.route('/inspections', methods=['POST']) @@ -55,22 +153,20 @@ def create_inspection(): Idempotency: if mobile_local_id is provided and an inspection with that 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 ------------ { "template_id": 3, "facility_id": 7, - "area_id": 12, // optional - "status": "completed", // "in_progress" | "completed" - "form_data": { ... }, // { field_id: value } - "notes": "...", // optional inspector notes - "overall_score": 87.5, // optional — computed server-side if omitted + "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", // required when status=completed - "mobile_local_id": "uuid-string" // idempotency key + "completed_at": "2026-05-01T15:00:00", + "mobile_local_id": "uuid-string" } Response 200 @@ -129,7 +225,6 @@ def create_inspection(): inspection_date = _parse_datetime(data.get('inspection_date')) or now_eastern() completed_at = _parse_datetime(data.get('completed_at')) - import json form_data = data.get('form_data') or {} notes_payload = {} if data.get('notes'): @@ -150,9 +245,9 @@ def create_inspection(): ) db.session.add(inspection) - db.session.flush() # get inspection.id before commit + db.session.flush() - # ── Notifications (completed inspections only) ───────────────────────── + # ── Notifications ───────────────────────────────────────────────────── if status == 'completed': score_display = f'{overall_score:.1f}%' if overall_score is not None else 'N/A' try: @@ -197,9 +292,9 @@ def create_inspection(): @jwt_required 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", @@ -208,10 +303,6 @@ def update_inspection(inspection_id): "overall_score": 91.0, "completed_at": "2026-05-01T15:30:00" } - - Response 200 - ------------ - { "ok": true, "data": { "inspection_id": 42 } } """ user = g.api_user @@ -222,13 +313,11 @@ def update_inspection(inspection_id): if inspection is None: return api_error('Inspection not found', 404) - # Inspectors can only update their own inspections if user.role == 'inspector' and inspection.inspector_id != user.id: return api_error('Access denied', 403) data = request.get_json(silent=True) or {} - import json if 'form_data' in data or 'notes' in data: existing_notes = {} if inspection.notes: @@ -269,13 +358,12 @@ def update_inspection(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): """ Mirror of routes/inspections.py::_compute_score_from_form(). - Returns float 0–100 or None if no scoreable fields exist. - Rating value 0 means unanswered — excluded from calculation. + Rating value 0 = unanswered — excluded from calculation. """ scoreable = [f for f in form_fields if f.get('type') in ('rating', 'checkbox', 'radio', 'pass_fail')] @@ -286,8 +374,8 @@ def _compute_score(form_fields, responses): 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) @@ -299,7 +387,7 @@ def _compute_score(form_fields, responses): pass elif ftype == 'checkbox': - total += 1 + total += 1 if val == 'true': earned += 1