Jul 13 - Update web API to support iOS app on scheduled inspection and issue's handler

This commit is contained in:
Nguyen Ngo
2026-07-13 12:36:28 -04:00
parent d7be9b8245
commit 729f1a6df8
4 changed files with 203 additions and 0 deletions
+4
View File
@@ -50,4 +50,8 @@ def register_api(app):
from app.api.comments import bp as comments_bp
api_bp.register_blueprint(comments_bp)
# Scheduled/recurring inspection assignments (phase36) — mobile list
from app.api.scheduled import bp as scheduled_bp
api_bp.register_blueprint(scheduled_bp)
app.register_blueprint(api_bp)
+93
View File
@@ -44,6 +44,7 @@ bp = Blueprint('api_issues', __name__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
_VALID_SEVERITY = {'low', 'medium', 'high', 'critical'}
_VALID_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'}
_VALID_HANDLERS = {'internal', 'facility', 'vendor'}
_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,
@@ -81,6 +82,18 @@ def _issue_payload(issue):
'area_name': issue.area.name if issue.area else None,
# Assigned-to display name — set when a director assigns the issue to a user.
'assigned_to_name': issue.assigned_user.display_name if issue.assigned_user else None,
# ── Handler ("Handled By", phase35) ───────────────────────────────
# handler_type categorises WHO resolves the issue:
# internal = our staff (assigned_to) facility = facility's own staff
# vendor = external contractor
'handler_type': issue.handler_type or 'internal',
'handler_label': issue.handler_label,
'facility_handler_name': issue.facility_handler_name or None,
'facility_handler_contact': issue.facility_handler_contact or None,
'facility_handler_notes': issue.facility_handler_notes or None,
'vendor_name': issue.vendor_name or None,
'vendor_contact': issue.vendor_contact or None,
'vendor_notes': issue.vendor_notes or None,
}
@@ -373,6 +386,86 @@ def update_issue_status(issue_id):
return api_ok({'issue_id': issue.id, 'status': issue.status})
# ── Update Issue Handler ("Handled By") ───────────────────────────────────────
@bp.route('/issues/<int:issue_id>/handler', methods=['PATCH'])
@jwt_required
def update_issue_handler(issue_id):
"""
Set who handles an issue ("Handled By") from the mobile app.
Unlike the web form (which limits handler edits to admin/director/PM),
the iPad allows the assigned inspector to set the handler from the field,
scoped to issues at their assigned facilities.
Request JSON
------------
{
"handler_type": "internal" | "facility" | "vendor",
"facility_handler_name": "...", // optional (facility handler)
"facility_handler_contact": "...", // optional
"facility_handler_notes": "...", // optional
"vendor_name": "...", // optional (vendor handler)
"vendor_contact": "...", // optional
"vendor_notes": "..." // optional
}
Only keys present in the body are updated; empty strings clear a field.
handler_type is required.
Access:
- admin / director / project_manager : any issue
- inspector : only issues at their assigned facilities
"""
user = g.api_user
if user.role not in _ALLOWED_ROLES:
return api_error('Access denied', 403)
issue = db.session.get(Issue, issue_id)
if issue is None:
return api_error('Issue not found', 404)
if user.role == 'inspector':
fids = get_inspector_scope(user)
facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids:
return api_error('Access denied', 403)
data = request.get_json(silent=True) or {}
handler = (data.get('handler_type') or '').strip().lower()
if handler not in _VALID_HANDLERS:
return api_error(
f'handler_type must be one of: {", ".join(sorted(_VALID_HANDLERS))}', 400
)
old_handler = issue.handler_type or 'internal'
issue.handler_type = handler
# Update only the detail fields that were supplied. Empty string clears
# the field (stored as NULL); a missing key leaves the field untouched.
_text_fields = (
'facility_handler_name', 'facility_handler_contact', 'facility_handler_notes',
'vendor_name', 'vendor_contact', 'vendor_notes',
)
for field in _text_fields:
if field in data:
val = (data.get(field) or '').strip()
setattr(issue, field, val or None)
db.session.commit()
log_action(ACTION_UPDATE, 'Issue', issue.id,
f'handler {old_handler}{handler}',
f'source=mobile; updated_by={user.username}')
logger.info('API ISSUES | handler_updated | issue_id=%d | %s%s | user=%s',
issue.id, old_handler, handler, user.username)
return api_ok({'issue_id': issue.id, 'handler_type': issue.handler_type})
# ── Update Issue Photos (mobile) ──────────────────────────────────────────────
@bp.route('/issues/<int:issue_id>/photos', methods=['PATCH'])
+104
View File
@@ -0,0 +1,104 @@
"""
app/api/scheduled.py
--------------------
Mobile API endpoint for planned/recurring inspection assignments (phase36).
GET /api/v1/scheduled-inspections
Returns ACTIVE scheduled inspections the caller is responsible for.
- inspector : only schedules where inspector_id == the caller
- admin / director / project_manager : all active schedules
Powers the "Scheduled" section on the iPad Dashboard and My Inspections
lists. The iPad taps "Start", which opens the normal new-inspection flow
with the facility + template preselected (client-side); the schedule
lifecycle (fulfil / roll-forward) continues to be driven by the web app.
A ScheduledInspection is a PLAN, not an inspection — see
app/models/scheduled_inspection.py for the full lifecycle.
"""
import logging
from flask import Blueprint, request, g
from app.models.scheduled_inspection import ScheduledInspection
from app.api.errors import api_ok, api_error
from app.api.decorators import jwt_required
from app.utils.scope import get_inspector_scope
logger = logging.getLogger(__name__)
bp = Blueprint('api_scheduled', __name__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
def _scheduled_payload(s):
"""Serialise a ScheduledInspection to the dict returned in list responses."""
return {
'id': s.id,
'facility_id': s.facility_id,
'facility_name': s.facility.name if s.facility else None,
'template_id': s.template_id,
'template_name': s.template.name if s.template else None,
'inspector_id': s.inspector_id,
'frequency': s.frequency,
'frequency_label': s.frequency_label,
'next_due_date': s.next_due_date.isoformat() if s.next_due_date else None,
'is_overdue': s.is_overdue(),
'notes': s.notes or None,
}
# ── List Scheduled Inspections ────────────────────────────────────────────────
@bp.route('/scheduled-inspections', methods=['GET'])
@jwt_required
def list_scheduled():
"""
Return active scheduled inspections for the authenticated user.
Query parameters
----------------
limit int Default 100, max 200.
offset int Default 0.
Response 200
------------
{
"ok": true,
"data": {
"scheduled": [...],
"total": 3,
"limit": 100,
"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', 100)), 200)
offset = max(int(request.args.get('offset', 0)), 0)
query = ScheduledInspection.query.filter(ScheduledInspection.active.is_(True))
if user.role == 'inspector':
# Inspectors only see schedules assigned directly to them.
query = query.filter(ScheduledInspection.inspector_id == user.id)
total = query.count()
rows = (
query
.order_by(ScheduledInspection.next_due_date.asc())
.offset(offset)
.limit(limit)
.all()
)
payload = [_scheduled_payload(s) for s in rows]
logger.info('API SCHEDULED | list | user=%s | count=%d | total=%d',
user.username, len(payload), total)
return api_ok({'scheduled': payload, 'total': total,
'limit': limit, 'offset': offset})