""" 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/ 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 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.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', 'project_manager'} def _parse_datetime(value): """Parse an ISO 8601 datetime string; return None on failure.""" if not value: return None from datetime import datetime for fmt in ('%Y-%m-%dT%H:%M:%S', '%Y-%m-%dT%H:%M:%S.%f', '%Y-%m-%d'): try: return datetime.strptime(value, fmt) except (ValueError, TypeError): pass 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, # ── Follow-up / re-inspection fields ────────────────────────────── 'follow_up_required': inspection.follow_up_required, 'follow_up_note': inspection.follow_up_note, 'parent_inspection_id': inspection.parent_inspection_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']) @jwt_required 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" } 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: 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 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, ) db.session.add(inspection) db.session.flush() # ── 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. 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, ) log_action(ACTION_UPDATE, 'Inspection', 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.username} 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, exclude_user_ids = {user.id}, ) db.session.commit() 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/', 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" } """ 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.role == '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'] = data['form_data'] if 'notes' in data: existing_notes['_inspector_notes'] = data['notes'] inspection.notes = json.dumps(existing_notes) 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() db.session.commit() 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