05/02 Phase B - update correct api/inspection.py
This commit is contained in:
+306
-28
@@ -1,40 +1,318 @@
|
|||||||
"""
|
"""
|
||||||
app/api/__init__.py
|
app/api/inspections.py
|
||||||
-------------------
|
----------------------
|
||||||
Registers the /api/v1 blueprint group.
|
Mobile API endpoints for submitting inspections from the iPad app.
|
||||||
|
|
||||||
Phase A: /api/v1/facilities/*, /api/v1/templates/*
|
POST /api/v1/inspections
|
||||||
Phase B: /api/v1/inspections/*, /api/v1/issues/*, /api/v1/photos/*
|
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/<inspection_id>
|
||||||
|
Updates an existing inspection (e.g. draft → completed).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Blueprint
|
import logging
|
||||||
from app.api.errors import register_error_handlers
|
|
||||||
|
|
||||||
api_bp = Blueprint('api', __name__, url_prefix='/api/v1')
|
from flask import Blueprint, request, g, current_app
|
||||||
register_error_handlers(api_bp)
|
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.
|
Create a new inspection submitted from the iPad app.
|
||||||
CSRF exemption for each child blueprint is handled in app/__init__.py.
|
|
||||||
|
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
|
user = g.api_user
|
||||||
from app.api.auth import bp as auth_bp
|
|
||||||
api_bp.register_blueprint(auth_bp)
|
|
||||||
|
|
||||||
# Phase A: Reference data
|
if user.role not in _ALLOWED_ROLES:
|
||||||
from app.api.facilities import bp as facilities_bp
|
return api_error('Access denied', 403)
|
||||||
from app.api.templates import bp as templates_bp
|
|
||||||
api_bp.register_blueprint(facilities_bp)
|
|
||||||
api_bp.register_blueprint(templates_bp)
|
|
||||||
|
|
||||||
# Phase B: Offline inspection submission
|
data = request.get_json(silent=True) or {}
|
||||||
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)
|
|
||||||
|
|
||||||
app.register_blueprint(api_bp)
|
# ── 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/<int:inspection_id>', 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
|
||||||
Reference in New Issue
Block a user