From 5ed433aabeab6c3f94b2a8ed3a83285cbbcb29f3 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Thu, 30 Jul 2026 16:08:58 -0400 Subject: [PATCH] Jul 30 - Update backend for iPad - inspector can re-inspect and create follow-up --- app/api/inspections.py | 13 ++ app/api/scheduled.py | 124 ++++++++++++++++++ app/models/scheduled_inspection.py | 21 +++ app/routes/scheduled_inspections.py | 6 + .../phase45_scheduled_parent_inspection.py | 82 ++++++++++++ 5 files changed, 246 insertions(+) create mode 100644 migrations/versions/phase45_scheduled_parent_inspection.py diff --git a/app/api/inspections.py b/app/api/inspections.py index 6da968f..46d0f0f 100644 --- a/app/api/inspections.py +++ b/app/api/inspections.py @@ -421,6 +421,19 @@ def create_inspection(): sched = _resolve_schedule(data['scheduled_inspection_id'], user) scheduled_inspection_id = sched.id if sched else None + # phase45 — 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 should 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 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) + # ── Score calculation ───────────────────────────────────────────────── overall_score = data.get('overall_score') if overall_score is None and status == 'completed': diff --git a/app/api/scheduled.py b/app/api/scheduled.py index 566081f..97adf01 100644 --- a/app/api/scheduled.py +++ b/app/api/scheduled.py @@ -17,12 +17,16 @@ app/models/scheduled_inspection.py for the full lifecycle. """ import logging +from datetime import datetime from flask import Blueprint, request, g +from app import db from app.models.scheduled_inspection import ScheduledInspection +from app.models.inspection import Inspection from app.api.errors import api_ok, api_error from app.api.decorators import jwt_required from app.utils.scope import get_inspector_scope +from app.utils.time_utils import now_eastern logger = logging.getLogger(__name__) @@ -56,6 +60,11 @@ def _scheduled_payload(s): 'end_date': s.end_date.isoformat() if s.end_date else None, 'is_overdue': s.is_overdue(), 'notes': s.notes or None, + # phase45. Set when this schedule is a planned follow-up of a specific + # inspection; the iPad carries it onto the inspection it starts so the + # run lands as a linked re-inspection. Additive — older builds decode + # explicit CodingKeys and ignore it. + 'parent_inspection_id': s.parent_inspection_id, } @@ -113,3 +122,118 @@ def list_scheduled(): return api_ok({'scheduled': payload, 'total': total, 'limit': limit, 'offset': offset}) + + +# ── Create a scheduled follow-up (phase45) ──────────────────────────────────── + +@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 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, 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`). + + 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/201 + ---------------- + { "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 = (ScheduledInspection.query + .filter_by(parent_inspection_id=parent.id, active=True) + .order_by(ScheduledInspection.id.desc()) + .first()) + if existing is not None: + existing.next_due_date = due_date + if notes: + existing.notes = notes + db.session.commit() + 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}) + + sched = ScheduledInspection( + facility_id = parent.facility_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', + next_due_date = due_date, + active = True, + notes = notes, + parent_inspection_id = parent.id, + created_by = user.id, + ) + db.session.add(sched) + db.session.commit() + + 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/scheduled_inspection.py b/app/models/scheduled_inspection.py index 62f64ea..b477b7b 100644 --- a/app/models/scheduled_inspection.py +++ b/app/models/scheduled_inspection.py @@ -99,6 +99,20 @@ class ScheduledInspection(db.Model): active = db.Column(db.Boolean, nullable=False, default=True) notes = db.Column(db.Text, nullable=True) + # ── Follow-up link (phase45) ───────────────────────────────────────────── + # 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 + # inspection eventually started from this schedule inherits it as its + # parent_inspection_id, so it 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-phase45 row + # is. + parent_inspection_id = db.Column( + db.Integer, + db.ForeignKey('inspections.id', ondelete='SET NULL'), + nullable=True, index=True, + ) + # ── Recurrence detail (phase43) ────────────────────────────────────────── # weekly : CSV of Python weekday ints, e.g. '0,2,4' = Mon/Wed/Fri. # NULL/empty falls back to the legacy "every 7 days" behaviour. @@ -126,6 +140,13 @@ class ScheduledInspection(db.Model): template = db.relationship('InspectionTemplate', foreign_keys=[template_id]) inspector = db.relationship('User', foreign_keys=[inspector_id]) creator = db.relationship('User', foreign_keys=[created_by]) + # Explicit foreign_keys is required, not optional: inspections and + # scheduled_inspections now reference each other (Inspection + # .scheduled_inspection_id points here, parent_inspection_id points back), + # so SQLAlchemy cannot infer the join for either side. Inspection + # .scheduled_inspection is already declared the same way. + parent_inspection = db.relationship('Inspection', + foreign_keys=[parent_inspection_id]) FREQUENCY_LABELS = { 'once': 'One-time', diff --git a/app/routes/scheduled_inspections.py b/app/routes/scheduled_inspections.py index 1b7e142..b225039 100644 --- a/app/routes/scheduled_inspections.py +++ b/app/routes/scheduled_inspections.py @@ -356,6 +356,12 @@ def start(schedule_id): inspection_date = now_eastern(), status = 'in_progress', scheduled_inspection_id = sched.id, + # phase45 — a schedule created by "Schedule Follow-up" carries the + # inspection it is a follow-up of. 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-phase45 row. + parent_inspection_id = sched.parent_inspection_id, ) db.session.add(inspection) db.session.commit() diff --git a/migrations/versions/phase45_scheduled_parent_inspection.py b/migrations/versions/phase45_scheduled_parent_inspection.py new file mode 100644 index 0000000..5b7e71b --- /dev/null +++ b/migrations/versions/phase45_scheduled_parent_inspection.py @@ -0,0 +1,82 @@ +"""phase45 — scheduled follow-up: link a schedule back to its parent inspection + +Adds `parent_inspection_id` to `scheduled_inspections`: + + parent_inspection_id INT NULL -- inspection this schedule is a follow-up of + +Lets a follow-up be *planned for a later date* rather than started immediately. +The iPad's inspection history detail gains a "Schedule Follow-up" action next to +"Re-inspect Now": it creates a one-time schedule carrying this column, and when +the inspector eventually starts it the resulting inspection inherits +`parent_inspection_id` — so it lands as a true linked re-inspection (pre-filled +from the parent, clearing the parent's `follow_up_required` on submit) exactly +as if they had tapped "Re-inspect Now" on the day. + +Without the column the scheduled run would be an ordinary inspection: no link, +no prefill, and the parent's follow-up flag would stay set forever. + +NULL means "not a follow-up", which is what every existing row is, so there is +no backfill and no schedule changes behaviour on deploy. + +ON DELETE SET NULL: deleting the parent inspection must not cascade away a +schedule the inspector still has to perform — it just stops being a follow-up. + +Uses an INFORMATION_SCHEMA existence check — safe to re-run. Additive only. +""" + +revision = 'phase45_sched_parent_insp' +down_revision = 'phase44_sched_end_date' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def _column_exists(conn, table, column): + result = conn.execute(sa.text( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() " + "AND TABLE_NAME = :t AND COLUMN_NAME = :c" + ), {"t": table, "c": column}) + return result.scalar() > 0 + + +def _constraint_exists(conn, table, name): + result = conn.execute(sa.text( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS " + "WHERE TABLE_SCHEMA = DATABASE() " + "AND TABLE_NAME = :t AND CONSTRAINT_NAME = :n" + ), {"t": table, "n": name}) + return result.scalar() > 0 + + +def upgrade(): + bind = op.get_bind() + if not _column_exists(bind, 'scheduled_inspections', 'parent_inspection_id'): + op.execute(sa.text( + "ALTER TABLE scheduled_inspections " + "ADD COLUMN parent_inspection_id INT NULL AFTER notes" + )) + if not _constraint_exists(bind, 'scheduled_inspections', + 'fk_sched_insp_parent_inspection'): + op.execute(sa.text( + "ALTER TABLE scheduled_inspections " + "ADD CONSTRAINT fk_sched_insp_parent_inspection " + "FOREIGN KEY (parent_inspection_id) REFERENCES inspections(id) " + "ON DELETE SET NULL" + )) + + +def downgrade(): + bind = op.get_bind() + if _constraint_exists(bind, 'scheduled_inspections', + 'fk_sched_insp_parent_inspection'): + op.execute(sa.text( + "ALTER TABLE scheduled_inspections " + "DROP FOREIGN KEY fk_sched_insp_parent_inspection" + )) + if _column_exists(bind, 'scheduled_inspections', 'parent_inspection_id'): + op.execute(sa.text( + "ALTER TABLE scheduled_inspections DROP COLUMN parent_inspection_id" + ))