Files
JQC_multi_tenant/app/api/inspections.py
T
2026-06-27 20:21:33 -04:00

604 lines
24 KiB
Python

"""
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/<inspection_id>
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
import re
from datetime import datetime
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.tenancy.gates import feature_required, quota_soft_check
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 _merge_form_data(existing: dict, incoming: dict) -> dict:
"""Merge incoming form_data into existing, preserving file paths.
New non-empty values always win. The one exception: an empty string
coming from the client will NOT overwrite an existing server-side upload
path (any value that starts with 'uploads/'). This protects photo paths
stored during an earlier POST from being silently blanked when the iOS
sends a final PATCH whose form_data was rebuilt without re-including the
already-uploaded paths.
"""
merged = dict(existing)
for k, v in incoming.items():
existing_v = merged.get(k)
if (not v
and isinstance(existing_v, str)
and existing_v.startswith('uploads/')):
continue # keep the saved photo path
merged[k] = v
return merged
_UUID_RE = re.compile(
r'^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$',
re.IGNORECASE,
)
def _parse_datetime(value):
"""Parse an ISO 8601 datetime string; return a naive Eastern datetime.
Handles the formats produced by both the web forms and iOS
ISO8601DateFormatter():
2026-05-28T09:41:00 (web form, already Eastern-naive)
2026-05-28T09:41:00.000 (web form with ms)
2026-05-28T09:41:00Z (iOS ISO8601DateFormatter, UTC)
2026-05-28T09:41:00.000000Z (iOS with fractional seconds, UTC)
Values ending with 'Z' are treated as UTC and converted to Eastern.
Values without a timezone suffix are assumed to already be Eastern-local.
"""
if not value:
return None
from datetime import datetime, timezone as _tz
from app.utils.time_utils import EASTERN
is_utc = isinstance(value, str) and value.endswith('Z')
normalised = value.rstrip('Z') if isinstance(value, str) else value
for fmt in ('%Y-%m-%dT%H:%M:%S', '%Y-%m-%dT%H:%M:%S.%f', '%Y-%m-%d'):
try:
dt = datetime.strptime(normalised, fmt)
if is_utc:
dt = (dt.replace(tzinfo=_tz.utc)
.astimezone(EASTERN)
.replace(tzinfo=None))
return dt
except (ValueError, TypeError):
pass
return None
def _inspection_payload(inspection):
"""Serialize an Inspection to the dict returned in API responses."""
# Extract form responses from the notes JSON blob.
# Mobile submissions store form data as {"_form_data": {...}, "_inspector_notes": "..."}.
# Web submissions store form data in the form_data column directly.
form_data = {}
inspector_notes = ''
if inspection.notes:
try:
notes_obj = json.loads(inspection.notes)
if isinstance(notes_obj, dict):
form_data = notes_obj.get('_form_data', {}) or {}
inspector_notes = notes_obj.get('_inspector_notes', '') or ''
except (json.JSONDecodeError, TypeError):
pass
# Fallback: web-created inspections store responses in form_data column
if not form_data and inspection.form_data:
form_data = inspection.form_data if isinstance(inspection.form_data, dict) else {}
# Include the template's form_schema so the iPad can render history
# without needing a locally cached copy of the template.
form_schema = []
if inspection.template:
form_schema = inspection.template.get_form_schema()
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': float(inspection.overall_score) if inspection.overall_score is not None else None,
'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,
'form_data': form_data,
'form_schema': form_schema,
'inspector_notes': inspector_notes,
# ── 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)
from_date str ISO date (YYYY-MM-DD) — include inspections on/after this date
to_date str ISO date (YYYY-MM-DD) — include inspections on/before this date
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)
from_date_str = request.args.get('from_date')
if from_date_str:
try:
from_dt = datetime.strptime(from_date_str, '%Y-%m-%d').date()
query = query.filter(Inspection.inspection_date >= from_dt)
except ValueError:
pass # malformed date — ignore silently
to_date_str = request.args.get('to_date')
if to_date_str:
try:
to_dt = datetime.strptime(to_date_str, '%Y-%m-%d').date()
query = query.filter(Inspection.inspection_date <= to_dt)
except ValueError:
pass # malformed date — ignore silently
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
@feature_required('mobile_api')
@quota_soft_check('inspections')
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:
if not _UUID_RE.match(str(mobile_local_id)):
return api_error('mobile_local_id must be a valid UUID', 400)
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
# If the client sent completed_at but it failed to parse (e.g. unrecognised
# format), fall back to now rather than storing NULL. This mirrors the
# PATCH handler's behaviour.
if completed_at is None and status == 'completed':
completed_at = now_eastern()
# ── GPS (mobile submission) ───────────────────────────────────────────
# The iPad sends submit_latitude / submit_longitude when CoreLocation
# granted permission and a fix was obtained before the inspector confirmed
# submission. Both fields are nullable — absence is silently ignored.
_lat = data.get('submit_latitude')
_lng = data.get('submit_longitude')
try:
submit_latitude = float(_lat) if _lat is not None else None
submit_longitude = float(_lng) if _lng is not None else None
except (ValueError, TypeError):
submit_latitude = None
submit_longitude = None
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,
submit_latitude = submit_latitude,
submit_longitude = submit_longitude,
)
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.
# Capture parent label strings before commit while ORM objects are loaded.
# log_action() for the parent update must fire AFTER db.session.commit() to
# avoid audit.py's internal commit() persisting the parent flag change before
# the new inspection row is committed — a partial state that would be incorrect
# if the main commit subsequently failed.
_parent_log_args = None
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,
)
# Snapshot label strings now — ORM objects may be expired after commit
_parent_log_args = (
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.display_name} 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,
)
db.session.commit()
# ── Post-commit audit logging ──────────────────────────────────────────
# All log_action() calls must come AFTER db.session.commit() because
# audit.py calls db.session.commit() internally. Calling it before the
# main commit would persist the audit row (and any dirty ORM state) before
# the primary transaction completes.
if _parent_log_args:
log_action(ACTION_UPDATE, 'Inspection', *_parent_log_args)
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/<int:inspection_id>', 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'] = _merge_form_data(
existing_notes.get('_form_data') or {},
data['form_data'] or {},
)
if 'notes' in data:
existing_notes['_inspector_notes'] = data['notes']
inspection.notes = json.dumps(existing_notes)
prev_status = inspection.status
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()
# Notify when a draft transitions to completed — mirrors the POST handler.
transitioning_to_complete = (
data.get('status') == 'completed' and prev_status != 'completed'
)
if transitioning_to_complete:
score_val = inspection.overall_score
score_display = f'{score_val:.1f}%' if score_val is not None else 'N/A'
template_name = inspection.template.name if inspection.template else 'Unknown'
facility_name = inspection.facility.name if inspection.facility else 'Unknown'
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.display_name} 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 = inspection.facility_id,
)
db.session.commit() # persist notification rows added by notify()
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