293 lines
12 KiB
Python
293 lines
12 KiB
Python
"""
|
|
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', 'external_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.is_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', 'external_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.is_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)
|