Aug 4 - Update code to follow up - MT12b

This commit is contained in:
2026-08-04 13:37:29 -04:00
parent 73ed0157fc
commit f4c80cfcef
7 changed files with 762 additions and 2 deletions
+13
View File
@@ -467,6 +467,19 @@ def create_inspection():
_sched = _resolve_schedule(_sched_id, user)
inspection_schedule_id = _sched.id if _sched else None
# phase48 — inherit the follow-up link from the schedule when the client
# did not send one. A schedule created by "Schedule Follow-up" knows
# which inspection it answers, so the link must not depend on the client
# remembering to pass it: an older build, or a draft resumed after the
# cached row was refreshed, would otherwise submit a plain inspection
# and leave the parent flagged forever. Never overrides an explicit
# parent_inspection_id.
if not parent_inspection_id and _sched is not None and _sched.parent_inspection_id:
parent_inspection_id = _sched.parent_inspection_id
logger.info('API INSPECTIONS | parent inherited from schedule | '
'schedule=%s | parent=%s | user=%s',
_sched.id, parent_inspection_id, user.username)
inspection = Inspection(
template_id = template_id,
facility_id = facility_id,
+151
View File
@@ -24,11 +24,17 @@ 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__)
@@ -69,6 +75,10 @@ def _scheduled_payload(s):
# 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,
'notes': s.notes or None,
}
@@ -133,3 +143,144 @@ def list_scheduled():
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)