diff --git a/app/api/inspections.py b/app/api/inspections.py index 6f1a2c8..c8c74a8 100644 --- a/app/api/inspections.py +++ b/app/api/inspections.py @@ -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, diff --git a/app/api/scheduled.py b/app/api/scheduled.py index a3f7310..b7e118e 100644 --- a/app/api/scheduled.py +++ b/app/api/scheduled.py @@ -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) diff --git a/app/models/inspection_schedule.py b/app/models/inspection_schedule.py index 654ec76..b7cb9db 100644 --- a/app/models/inspection_schedule.py +++ b/app/models/inspection_schedule.py @@ -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', diff --git a/app/routes/inspection_schedules.py b/app/routes/inspection_schedules.py index 2fe0fa5..9b9184e 100644 --- a/app/routes/inspection_schedules.py +++ b/app/routes/inspection_schedules.py @@ -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') diff --git a/app/templates/inspection_schedules/index.html b/app/templates/inspection_schedules/index.html index beb21d7..d1f6481 100644 --- a/app/templates/inspection_schedules/index.html +++ b/app/templates/inspection_schedules/index.html @@ -38,7 +38,18 @@
{% for s in schedules %}