""" 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 datetime import datetime from flask import Blueprint, request, g from app import db from app.models.scheduled_inspection import ScheduledInspection from app.models.inspection import Inspection from app.api.errors import api_ok, api_error from app.api.decorators import jwt_required from app.utils.scope import get_inspector_scope from app.utils.time_utils import now_eastern logger = logging.getLogger(__name__) bp = Blueprint('api_scheduled', __name__) _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'} 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, # phase43 recurrence detail. `recurrence_label` is the display string # ("Weekly · Mon, Wed, Fri"); the raw fields let the iPad render its own. 'recurrence_label': s.recurrence_label, 'weekdays': s.weekday_list, 'month_mode': s.month_mode, 'day_of_month': s.day_of_month, 'nth_week': s.nth_week, 'nth_weekday': s.nth_weekday, 'next_due_date': s.next_due_date.isoformat() if s.next_due_date else None, # phase44. Additive: the iPad decodes explicit CodingKeys, so a build # that predates this key ignores it rather than failing to decode. 'end_date': s.end_date.isoformat() if s.end_date else None, 'is_overdue': s.is_overdue(), 'notes': s.notes or None, # phase45. Set when this schedule is a planned follow-up of a specific # inspection; the iPad carries it onto the inspection it starts so the # run lands as a linked re-inspection. Additive — older builds decode # explicit CodingKeys and ignore it. 'parent_inspection_id': s.parent_inspection_id, } # ── 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}) # ── Create a scheduled follow-up (phase45) ──────────────────────────────────── @bp.route('/scheduled-inspections/follow-up', methods=['POST']) @jwt_required def create_follow_up(): """ Plan a follow-up re-inspection of a completed inspection for a later date. Backs "Schedule Follow-up" in the iPad's inspection history detail, the deferred twin of "Re-inspect Now". Creates a one-time schedule carrying `parent_inspection_id`, so the inspection eventually started from it is a true linked re-inspection. Deliberately narrow: this is not a general schedule-creation endpoint. The facility, template and assignee are all derived from the parent inspection rather than taken from the client, so a follow-up can only ever target the thing it is a follow-up of. Recurring schedules stay web-only (`@project_manager_required`). Request body ------------ parent_inspection_id int required — the completed inspection to follow up due_date str required — ISO date (YYYY-MM-DD), today or later notes str optional — what the follow-up should address Response 200/201 ---------------- { "ok": true, "data": { "scheduled": {...}, "created": true } } """ user = g.api_user # Auditor is read-only everywhere else; keep it that way here. if user.role not in {'admin', 'director', 'inspector', 'project_manager'}: return api_error('Access denied', 403) body = request.get_json(silent=True) or {} parent_id = body.get('parent_inspection_id') if not isinstance(parent_id, int): return api_error('parent_inspection_id is required', 400) parent = db.session.get(Inspection, parent_id) if parent is None: return api_error('Inspection not found', 404) # An inspector may only schedule a follow-up of their own work, and only # within their assigned contracts — the same two gates the rest of the # mobile API applies. Managers are unrestricted, matching the web. if user.role == 'inspector': if parent.inspector_id != user.id: return api_error('Access denied', 403) fids = get_inspector_scope(user) if not fids or parent.facility_id not in fids: return api_error('Access denied', 403) # A follow-up only makes sense once there is something to follow up on. if parent.status != 'completed': return api_error('Only a completed inspection can have a follow-up ' 'scheduled', 400) due_raw = (body.get('due_date') or '').strip() try: due_date = datetime.strptime(due_raw, '%Y-%m-%d').date() except ValueError: return api_error('due_date must be an ISO date (YYYY-MM-DD)', 400) # Today is allowed — "later today" is a legitimate plan; yesterday is not. if due_date < now_eastern().date(): return api_error('due_date cannot be in the past', 400) notes = (body.get('notes') or '').strip() or None # Idempotent: the iPad may retry a request whose response was lost, and a # second identical schedule would put a duplicate row in the inspector's # Scheduled list with no way to tell them apart. Reuse the existing active # follow-up for this parent instead, updating the date they just picked. existing = (ScheduledInspection.query .filter_by(parent_inspection_id=parent.id, active=True) .order_by(ScheduledInspection.id.desc()) .first()) if existing is not None: existing.next_due_date = due_date if notes: existing.notes = notes db.session.commit() logger.info('API SCHEDULED | follow-up updated | schedule=%s | ' 'parent=%s | due=%s | user=%s', existing.id, parent.id, due_date, user.username) return api_ok({'scheduled': _scheduled_payload(existing), 'created': False}) sched = ScheduledInspection( facility_id = parent.facility_id, template_id = parent.template_id, # Assign to whoever performed the original — they are the one being # asked to put it right. Falls back to the caller when the parent has # no inspector (its account was deleted). inspector_id = parent.inspector_id or user.id, frequency = 'once', next_due_date = due_date, active = True, notes = notes, parent_inspection_id = parent.id, created_by = user.id, ) db.session.add(sched) db.session.commit() logger.info('API SCHEDULED | follow-up created | schedule=%s | parent=%s | ' 'facility=%s | due=%s | user=%s', sched.id, parent.id, parent.facility_id, due_date, user.username) return api_ok({'scheduled': _scheduled_payload(sched), 'created': True}, 201)