Jul 30 - Update backend for iPad - inspector can re-inspect and create follow-up
This commit is contained in:
@@ -421,6 +421,19 @@ def create_inspection():
|
|||||||
sched = _resolve_schedule(data['scheduled_inspection_id'], user)
|
sched = _resolve_schedule(data['scheduled_inspection_id'], user)
|
||||||
scheduled_inspection_id = sched.id if sched else None
|
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 ─────────────────────────────────────────────────
|
# ── Score calculation ─────────────────────────────────────────────────
|
||||||
overall_score = data.get('overall_score')
|
overall_score = data.get('overall_score')
|
||||||
if overall_score is None and status == 'completed':
|
if overall_score is None and status == 'completed':
|
||||||
|
|||||||
@@ -17,12 +17,16 @@ app/models/scheduled_inspection.py for the full lifecycle.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from flask import Blueprint, request, g
|
from flask import Blueprint, request, g
|
||||||
|
from app import db
|
||||||
from app.models.scheduled_inspection import ScheduledInspection
|
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.errors import api_ok, api_error
|
||||||
from app.api.decorators import jwt_required
|
from app.api.decorators import jwt_required
|
||||||
from app.utils.scope import get_inspector_scope
|
from app.utils.scope import get_inspector_scope
|
||||||
|
from app.utils.time_utils import now_eastern
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -56,6 +60,11 @@ def _scheduled_payload(s):
|
|||||||
'end_date': s.end_date.isoformat() if s.end_date else None,
|
'end_date': s.end_date.isoformat() if s.end_date else None,
|
||||||
'is_overdue': s.is_overdue(),
|
'is_overdue': s.is_overdue(),
|
||||||
'notes': s.notes or None,
|
'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,
|
return api_ok({'scheduled': payload, 'total': total,
|
||||||
'limit': limit, 'offset': offset})
|
'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)
|
||||||
|
|||||||
@@ -99,6 +99,20 @@ class ScheduledInspection(db.Model):
|
|||||||
active = db.Column(db.Boolean, nullable=False, default=True)
|
active = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
notes = db.Column(db.Text, nullable=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) ──────────────────────────────────────────
|
# ── Recurrence detail (phase43) ──────────────────────────────────────────
|
||||||
# weekly : CSV of Python weekday ints, e.g. '0,2,4' = Mon/Wed/Fri.
|
# 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.
|
# 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])
|
template = db.relationship('InspectionTemplate', foreign_keys=[template_id])
|
||||||
inspector = db.relationship('User', foreign_keys=[inspector_id])
|
inspector = db.relationship('User', foreign_keys=[inspector_id])
|
||||||
creator = db.relationship('User', foreign_keys=[created_by])
|
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 = {
|
FREQUENCY_LABELS = {
|
||||||
'once': 'One-time',
|
'once': 'One-time',
|
||||||
|
|||||||
@@ -356,6 +356,12 @@ def start(schedule_id):
|
|||||||
inspection_date = now_eastern(),
|
inspection_date = now_eastern(),
|
||||||
status = 'in_progress',
|
status = 'in_progress',
|
||||||
scheduled_inspection_id = sched.id,
|
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.add(inspection)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
))
|
||||||
Reference in New Issue
Block a user