diff --git a/app/__init__.py b/app/__init__.py index 9d20bd3..d936422 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -213,6 +213,7 @@ def create_app(config_name='default'): from app.api.notifications import bp as _api_notifications_bp from app.api.stats import bp as _api_stats_bp from app.api.comments import bp as _api_comments_bp + from app.api.scheduled import bp as _api_scheduled_bp csrf.exempt(_api_auth_bp) csrf.exempt(_api_facilities_bp) csrf.exempt(_api_templates_bp) @@ -222,6 +223,7 @@ def create_app(config_name='default'): csrf.exempt(_api_notifications_bp) csrf.exempt(_api_stats_bp) csrf.exempt(_api_comments_bp) + csrf.exempt(_api_scheduled_bp) register_api(app) # ── Security response headers ───────────────────────────────────────── diff --git a/app/api/__init__.py b/app/api/__init__.py index 178697c..28ff6ab 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -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) \ No newline at end of file diff --git a/app/api/issues.py b/app/api/issues.py index 6604370..92a4310 100644 --- a/app/api/issues.py +++ b/app/api/issues.py @@ -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//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//photos', methods=['PATCH']) diff --git a/app/api/scheduled.py b/app/api/scheduled.py new file mode 100644 index 0000000..158a41b --- /dev/null +++ b/app/api/scheduled.py @@ -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})