""" app/api/scheduled.py -------------------- Mobile API endpoint for planned inspection assignments (phase43). GET /api/v1/scheduled-inspections Returns ACTIVE, PLAN-MODE schedules the caller is responsible for. - inspector : only schedules where inspector_id == the caller - admin / director / project_manager / auditor : all active plan 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. Why plan-mode only ------------------ `mode='auto'` schedules materialise themselves into a real Inspection at next_run_at, which the iPad already fetches via /api/v1/inspections. Returning them here too would show the same work twice, and "Start" is meaningless for a schedule that starts itself. This mirrors the web dashboard panel (phase43). A plan-mode schedule is a PLAN, not an inspection — see app/models/inspection_schedule.py for the full lifecycle. """ import logging from datetime import datetime from flask import Blueprint, request, g from app import db from app.models.inspection import Inspection from app.models.inspection_schedule import InspectionSchedule 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.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 an InspectionSchedule to the dict returned in list responses. `next_due_date` is the date part of next_run_at — MT reuses next_run_at as the due datetime for both modes (see phase43). """ return { 'id': s.id, 'name': s.name, 'facility_id': s.facility_id, 'facility_name': s.facility.name if s.facility else None, 'area_id': s.area_id, 'area_name': s.area.name if s.area 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, 'mode': s.mode, # phase46 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_run_at.date().isoformat() if s.next_run_at else None, # phase47. 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(), # phase48 — non-NULL when this schedule is a planned follow-up of a # completed inspection. The iPad uses it to badge the row and to open # the parent from the schedule detail. 'parent_inspection_id': s.parent_inspection_id, # phase50 — receipt acknowledgement, per assignment rather than per # occurrence. Lets the iPad badge unconfirmed assignments. 'is_acknowledged': s.is_acknowledged, 'acknowledged_at': s.acknowledged_at.isoformat() if s.acknowledged_at else None, 'notes': s.notes or None, } # ── List Scheduled Inspections ──────────────────────────────────────────────── @bp.route('/scheduled-inspections', methods=['GET']) @jwt_required def list_scheduled(): """ Return active plan-mode 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) try: limit = min(int(request.args.get('limit', 100)), 200) offset = max(int(request.args.get('offset', 0)), 0) except (TypeError, ValueError): return api_error('limit and offset must be integers', 400) query = InspectionSchedule.query.filter( InspectionSchedule.active.is_(True), InspectionSchedule.mode == 'plan', ) if user.role == 'inspector': # Inspectors only see schedules assigned directly to them. query = query.filter(InspectionSchedule.inspector_id == user.id) total = query.count() rows = ( query .order_by(InspectionSchedule.next_run_at.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 (phase48) ──────────────────────────────────── @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 (`frequency='once'`), plan-mode 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, area, 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`). Mode is forced to 'plan', never 'auto': a follow-up is something a person goes and does, and an auto schedule would drop an in-progress inspection into the queue unannounced on the due date. 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 (reused existing) / 201 (created) --------------------------------------------- { "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 = (InspectionSchedule.query .filter_by(parent_inspection_id=parent.id, active=True) .order_by(InspectionSchedule.id.desc()) .first()) if existing is not None: existing.set_next_run_date(due_date) if notes: existing.notes = notes # A moved due date is a new occurrence — the reminders already sent for # the old one no longer apply. existing.advance_notified = False existing.due_notified = False existing.overdue_notified = False db.session.commit() log_action(ACTION_UPDATE, 'InspectionSchedule', existing.id, existing.name, f'follow-up rescheduled via mobile API by {user.username}; ' f'parent_inspection_id={parent.id}; due={due_date}') 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}) fac_name = parent.facility.name if parent.facility else 'facility' sched = InspectionSchedule( # MT requires a name (ST's table does not). Build one rather than asking # the client for it, so the row is identifiable in the web schedule list # without the iPad needing to know MT's schema. name = f'Follow-up: {fac_name} (inspection #{parent.id})', facility_id = parent.facility_id, area_id = parent.area_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', mode = 'plan', active = True, notes = notes, parent_inspection_id = parent.id, created_by = user.id, created_at = now_eastern(), ) # set_next_run_date() rather than a raw next_run_at so the due date gets the # schedule's standard time-of-day (06:00 for a row with no next_run_at yet). sched.set_next_run_date(due_date) db.session.add(sched) db.session.commit() log_action(ACTION_CREATE, 'InspectionSchedule', sched.id, sched.name, f'follow-up created via mobile API by {user.username}; ' f'parent_inspection_id={parent.id}; facility_id={parent.facility_id}; ' f'due={due_date}') 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)