Aug 4 - Update code to follow up - MT12b
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -151,6 +151,26 @@ class InspectionSchedule(db.Model):
|
||||
mode = db.Column(db.Enum('auto', 'plan'), nullable=False, default='auto')
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
# ── Follow-up link (phase48) ─────────────────────────────────────────────
|
||||
# Set when this schedule was created as a follow-up of a specific completed
|
||||
# inspection ("Schedule Follow-up" in the iPad's history detail — the
|
||||
# deferred twin of "Re-inspect Now"). The inspection eventually started from
|
||||
# this schedule inherits it as its own parent_inspection_id, so the run
|
||||
# lands as a true linked re-inspection: pre-filled from the parent, and
|
||||
# clearing the parent's follow_up_required on submit. NULL = an ordinary
|
||||
# schedule, which is what every pre-phase48 row is.
|
||||
parent_inspection_id = db.Column(
|
||||
db.Integer,
|
||||
# use_alter + an explicit name: inspections and inspection_schedules now
|
||||
# reference each other, so metadata-driven CREATE/DROP cannot topologically
|
||||
# sort them. The name matches the constraint phase48 creates, so the ORM's
|
||||
# view of the schema and the migration's agree.
|
||||
db.ForeignKey('inspections.id', ondelete='SET NULL',
|
||||
name='fk_inspection_schedules_parent_inspection',
|
||||
use_alter=True),
|
||||
nullable=True, index=True,
|
||||
)
|
||||
|
||||
created_by = db.Column(
|
||||
db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'),
|
||||
nullable=True
|
||||
@@ -196,6 +216,17 @@ class InspectionSchedule(db.Model):
|
||||
area = db.relationship('Area', foreign_keys=[area_id])
|
||||
inspector = db.relationship('User', foreign_keys=[inspector_id])
|
||||
creator = db.relationship('User', foreign_keys=[created_by])
|
||||
# phase48. Explicit foreign_keys is required, not optional: inspections and
|
||||
# inspection_schedules now reference each other (Inspection
|
||||
# .inspection_schedule_id points here, parent_inspection_id points back), so
|
||||
# SQLAlchemy cannot infer the join for either side.
|
||||
parent_inspection = db.relationship('Inspection',
|
||||
foreign_keys=[parent_inspection_id])
|
||||
|
||||
@property
|
||||
def is_follow_up(self):
|
||||
"""True when this schedule was created to follow up an inspection."""
|
||||
return self.parent_inspection_id is not None
|
||||
|
||||
FREQUENCY_LABELS = {
|
||||
'once': 'One-time',
|
||||
|
||||
@@ -230,6 +230,12 @@ def _materialise(schedule: InspectionSchedule, when: datetime) -> Inspection:
|
||||
status = 'in_progress',
|
||||
notes = schedule.notes,
|
||||
inspection_schedule_id = schedule.id, # phase43 — link back to the plan
|
||||
# phase48 — a schedule created by "Schedule Follow-up" carries the
|
||||
# inspection it answers. Inheriting it here is what makes the run a real
|
||||
# linked re-inspection: execute() pre-fills from the parent and submit
|
||||
# clears the parent's follow_up_required. NULL for ordinary schedules,
|
||||
# which is every pre-phase48 row.
|
||||
parent_inspection_id = schedule.parent_inspection_id,
|
||||
)
|
||||
db.session.add(inspection)
|
||||
db.session.flush() # assign inspection.id without committing
|
||||
@@ -546,6 +552,18 @@ def start(schedule_id):
|
||||
flash('The template for this schedule has no form fields yet.', 'warning')
|
||||
return redirect(url_for('inspection_schedules.index'))
|
||||
|
||||
# Already started but not submitted? Resume it rather than opening a second
|
||||
# inspection against the same occurrence. Without this, a manager and the
|
||||
# inspector both pressing Start — or one double-tap — leaves two in_progress
|
||||
# rows against one schedule, only one of which fulfils it on submit.
|
||||
existing = (Inspection.query
|
||||
.filter_by(inspection_schedule_id=schedule.id, status='in_progress')
|
||||
.order_by(Inspection.id.desc())
|
||||
.first())
|
||||
if existing is not None:
|
||||
flash('Resuming the inspection you already started for this schedule.', 'info')
|
||||
return redirect(url_for('inspections.execute', inspection_id=existing.id))
|
||||
|
||||
inspection = Inspection(
|
||||
template_id = schedule.template_id,
|
||||
facility_id = schedule.facility_id,
|
||||
@@ -555,12 +573,15 @@ def start(schedule_id):
|
||||
status = 'in_progress',
|
||||
notes = schedule.notes,
|
||||
inspection_schedule_id = schedule.id,
|
||||
# phase48 — see _materialise().
|
||||
parent_inspection_id = schedule.parent_inspection_id,
|
||||
)
|
||||
db.session.add(inspection)
|
||||
db.session.commit()
|
||||
log_action(ACTION_CREATE, 'Inspection', inspection.id,
|
||||
f'{inspection.template.name} @ {inspection.facility.name}',
|
||||
f'started from inspection_schedule_id={schedule.id}')
|
||||
f'started from inspection_schedule_id={schedule.id}; '
|
||||
f'parent_inspection_id={schedule.parent_inspection_id}')
|
||||
logger.info('INSPECTION SCHEDULE | start | schedule=%s | inspection=%s | by=%s',
|
||||
schedule.id, inspection.id, current_user.username)
|
||||
flash('Inspection started from schedule. Complete and submit the form below.', 'info')
|
||||
|
||||
@@ -38,7 +38,18 @@
|
||||
<tbody>
|
||||
{% for s in schedules %}
|
||||
<tr class="{{ 'text-muted' if not s.active else '' }}">
|
||||
<td><strong>{{ s.name }}</strong></td>
|
||||
<td>
|
||||
<strong>{{ s.name }}</strong>
|
||||
{% if s.is_follow_up %}
|
||||
{# phase48 — a schedule planned as the deferred twin of
|
||||
"Re-inspect Now". Starting it produces a linked re-inspection. #}
|
||||
<a href="{{ url_for('inspections.view', inspection_id=s.parent_inspection_id) }}"
|
||||
class="badge bg-warning text-dark text-decoration-none ms-1"
|
||||
title="Follow-up of inspection #{{ s.parent_inspection_id }}">
|
||||
<i class="bi bi-arrow-repeat"></i> Follow-up #{{ s.parent_inspection_id }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ s.template.name if s.template else '—' }}</td>
|
||||
<td>
|
||||
{{ s.facility.name if s.facility else '—' }}
|
||||
|
||||
Reference in New Issue
Block a user