From fcbfd64c6821f3d97f75fff3d5d822d7dfb7d5f8 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Sat, 2 May 2026 13:33:02 -0400 Subject: [PATCH] 05/02 Phase B - update correct api/inspection.py --- app/api/inspections.py | 334 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 306 insertions(+), 28 deletions(-) diff --git a/app/api/inspections.py b/app/api/inspections.py index 03b474c..fffde6b 100644 --- a/app/api/inspections.py +++ b/app/api/inspections.py @@ -1,40 +1,318 @@ """ -app/api/__init__.py -------------------- -Registers the /api/v1 blueprint group. +app/api/inspections.py +---------------------- +Mobile API endpoints for submitting inspections from the iPad app. -Phase A: /api/v1/facilities/*, /api/v1/templates/* -Phase B: /api/v1/inspections/*, /api/v1/issues/*, /api/v1/photos/* +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. + +PATCH /api/v1/inspections/ + Updates an existing inspection (e.g. draft → completed). """ -from flask import Blueprint -from app.api.errors import register_error_handlers +import logging -api_bp = Blueprint('api', __name__, url_prefix='/api/v1') -register_error_handlers(api_bp) +from flask import Blueprint, request, g, current_app +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 register_api(app): +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 + + +# ── Create Inspection ───────────────────────────────────────────────────────── + +@bp.route('/inspections', methods=['POST']) +@jwt_required +def create_inspection(): """ - Register all API sub-blueprints. - CSRF exemption for each child blueprint is handled in app/__init__.py. + 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. + 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 + "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 + } + + Response 200 + ------------ + { "ok": true, "data": { "inspection_id": 42, "duplicate": false } } """ - # Phase 7: Auth - from app.api.auth import bp as auth_bp - api_bp.register_blueprint(auth_bp) + user = g.api_user - # Phase A: Reference data - from app.api.facilities import bp as facilities_bp - from app.api.templates import bp as templates_bp - api_bp.register_blueprint(facilities_bp) - api_bp.register_blueprint(templates_bp) + if user.role not in _ALLOWED_ROLES: + return api_error('Access denied', 403) - # Phase B: Offline inspection submission - from app.api.inspections import bp as inspections_bp - from app.api.issues import bp as issues_bp - from app.api.photos import bp as photos_bp - api_bp.register_blueprint(inspections_bp) - api_bp.register_blueprint(issues_bp) - api_bp.register_blueprint(photos_bp) + data = request.get_json(silent=True) or {} - app.register_blueprint(api_bp) \ No newline at end of file + # ── 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) + + # ── 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')) + + import json + 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, + ) + + db.session.add(inspection) + db.session.flush() # get inspection.id before commit + + # ── Notifications (completed inspections only) ───────────────────────── + 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}') + + 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. promoting a draft to completed. + + Request JSON (all fields optional — only provided fields are updated) + ------------ + { + "status": "completed", + "form_data": { ... }, + "notes": "...", + "overall_score": 91.0, + "completed_at": "2026-05-01T15:30:00" + } + + Response 200 + ------------ + { "ok": true, "data": { "inspection_id": 42 } } + """ + 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) + + # 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: + 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 (mirrors Python _compute_score_from_form exactly) ──────────── + +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. + """ + 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 \ No newline at end of file