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)
+31
View File
@@ -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',
+22 -1
View File
@@ -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')
+12 -1
View File
@@ -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 '—' }}
@@ -0,0 +1,115 @@
"""phase48 — scheduled follow-up: link a schedule back to its parent inspection
Ports single-tenant phase45 onto MT's `inspection_schedules` table. Adds:
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 deferred twin of "Re-inspect Now". A one-time schedule carrying this column
is created via POST /api/v1/scheduled-inspections/follow-up; 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, and
clearing the parent's `follow_up_required` on submit.
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.
Depends on phase45's `once` frequency: a follow-up is a single planned visit,
not a recurrence.
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.
This mirrors `inspections.inspection_schedule_id`, which points the other way
with the same rule, so neither side of the pair can delete the other's history.
Uses INFORMATION_SCHEMA existence checks safe to re-run on every tenant DB.
Additive only: nothing is renamed, retyped or dropped.
"""
revision = 'phase48_schedule_parent_inspection'
down_revision = 'phase47_schedule_end_date'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
_TABLE = 'inspection_schedules'
_COLUMN = 'parent_inspection_id'
_FK = 'fk_inspection_schedules_parent_inspection'
_INDEX = 'ix_inspection_schedules_parent_inspection_id'
def _table_exists(conn, table):
return conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t"
), {"t": table}).scalar() > 0
def _column_exists(conn, table, column):
return 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}).scalar() > 0
def _constraint_exists(conn, table, name):
return 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}).scalar() > 0
def _index_exists(conn, table, name):
return conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS "
"WHERE TABLE_SCHEMA = DATABASE() "
"AND TABLE_NAME = :t AND INDEX_NAME = :n"
), {"t": table, "n": name}).scalar() > 0
def upgrade():
bind = op.get_bind()
if not _table_exists(bind, _TABLE):
return
if not _column_exists(bind, _TABLE, _COLUMN):
op.execute(sa.text(
f"ALTER TABLE {_TABLE} ADD COLUMN {_COLUMN} INT NULL AFTER notes"
))
# Named explicitly so the downgrade and the re-run check can find it. MySQL
# auto-creates an index for a foreign key, but only when no usable index
# exists; creating it first means the name is ours and predictable.
if not _index_exists(bind, _TABLE, _INDEX):
op.execute(sa.text(
f"CREATE INDEX {_INDEX} ON {_TABLE} ({_COLUMN})"
))
if not _constraint_exists(bind, _TABLE, _FK):
op.execute(sa.text(
f"ALTER TABLE {_TABLE} ADD CONSTRAINT {_FK} "
f"FOREIGN KEY ({_COLUMN}) REFERENCES inspections(id) "
f"ON DELETE SET NULL"
))
def downgrade():
bind = op.get_bind()
if not _table_exists(bind, _TABLE):
return
# FK first — MySQL refuses to drop a column or index still referenced by one.
if _constraint_exists(bind, _TABLE, _FK):
op.execute(sa.text(f"ALTER TABLE {_TABLE} DROP FOREIGN KEY {_FK}"))
if _index_exists(bind, _TABLE, _INDEX):
op.execute(sa.text(f"DROP INDEX {_INDEX} ON {_TABLE}"))
if _column_exists(bind, _TABLE, _COLUMN):
op.execute(sa.text(f"ALTER TABLE {_TABLE} DROP COLUMN {_COLUMN}"))
+418
View File
@@ -0,0 +1,418 @@
"""
tests/test_schedule_follow_up.py
---------------------------------
Behaviour tests for phase48 scheduled follow-up.
Runs on the in-memory SQLite app fixture (multi-tenancy inert). Covers:
* POST /api/v1/scheduled-inspections/follow-up creates a one-time, plan-mode
schedule carrying parent_inspection_id, with facility/area/template/assignee
all derived from the parent rather than taken from the client
* idempotency a retry reuses the existing active follow-up and updates its
date instead of creating a duplicate row
* validation missing/incorrect parent, non-completed parent, bad or past
due_date, auditor role
* an inspector may only follow up their own work
* starting the schedule from the web produces an inspection that INHERITS
parent_inspection_id (what makes it a real linked re-inspection)
* start() resumes an in-progress run instead of opening a second one
* the cron materialiser inherits the link too
* the mobile API infers the parent from the schedule when the client omits it
"""
from datetime import date, datetime, timedelta
import pytest
@pytest.fixture
def client(app):
"""Fresh schema + test client for each test (isolated in-memory DB)."""
app.config['DIGEST_SECRET'] = 'test-digest'
with app.app_context():
from app import db
db.drop_all()
db.create_all()
yield app.test_client()
db.session.remove()
def _seed(suffix='a', role='inspector'):
from app import db
from app.models.user import User
from app.models.facility import Facility
from app.models.inspection import InspectionTemplate
user = User(username=f'u_{suffix}', full_name='Ivy Inspector',
email=f'u_{suffix}@example.com', role=role, active=True)
user.set_password('pw-correct1')
tmpl = InspectionTemplate(name='Restroom Check', active=True,
form_schema=[{'id': 'f1', 'type': 'rating_5', 'label': 'Clean'}])
fac = Facility(name='Main Office', active=True)
db.session.add_all([user, tmpl, fac])
db.session.commit()
return user, tmpl, fac
def _completed_inspection(user, tmpl, fac, area_id=None):
from app import db
from app.models.inspection import Inspection
from app.utils.time_utils import now_eastern
insp = Inspection(template_id=tmpl.id, facility_id=fac.id, area_id=area_id,
inspector_id=user.id, inspection_date=now_eastern(),
status='completed', completed_at=now_eastern(),
overall_score=62.5, follow_up_required=True)
db.session.add(insp)
db.session.commit()
return insp
def _auth(user):
from app.api.jwt_utils import generate_access_token
return {'Authorization': f'Bearer {generate_access_token(user)}'}
def _post_follow_up(client, user, **body):
return client.post('/api/v1/scheduled-inspections/follow-up',
json=body, headers=_auth(user))
# ── Creation ─────────────────────────────────────────────────────────────────
def test_follow_up_creates_one_time_plan_schedule_from_the_parent(client):
from app.models.inspection_schedule import InspectionSchedule
user, tmpl, fac = _seed('mk', role='project_manager')
parent = _completed_inspection(user, tmpl, fac)
due = date.today() + timedelta(days=7)
resp = _post_follow_up(client, user,
parent_inspection_id=parent.id,
due_date=due.isoformat(),
notes='Recheck the stalls')
assert resp.status_code == 201
data = resp.get_json()['data']
assert data['created'] is True
s = InspectionSchedule.query.get(data['scheduled']['id'])
assert s.parent_inspection_id == parent.id
assert s.is_follow_up is True
# Everything derived from the parent, nothing taken from the client.
assert s.facility_id == parent.facility_id
assert s.template_id == parent.template_id
assert s.inspector_id == parent.inspector_id
assert s.area_id == parent.area_id
# A follow-up is a single planned visit the inspector goes and does.
assert s.frequency == 'once'
assert s.mode == 'plan'
assert s.active is True
assert s.due_date == due
assert s.notes == 'Recheck the stalls'
assert str(parent.id) in s.name
def test_follow_up_ignores_client_supplied_facility_and_template(client):
"""The endpoint is narrow on purpose — a follow-up can only target the thing
it is a follow-up of."""
from app import db
from app.models.facility import Facility
from app.models.inspection import InspectionTemplate
from app.models.inspection_schedule import InspectionSchedule
user, tmpl, fac = _seed('narrow', role='admin')
parent = _completed_inspection(user, tmpl, fac)
other_fac = Facility(name='Other Site', active=True)
other_tmpl = InspectionTemplate(name='Other', active=True, form_schema=[])
db.session.add_all([other_fac, other_tmpl])
db.session.commit()
resp = _post_follow_up(client, user,
parent_inspection_id=parent.id,
due_date=(date.today() + timedelta(days=3)).isoformat(),
facility_id=other_fac.id,
template_id=other_tmpl.id,
frequency='daily', mode='auto')
s = InspectionSchedule.query.get(resp.get_json()['data']['scheduled']['id'])
assert s.facility_id == fac.id
assert s.template_id == tmpl.id
assert s.frequency == 'once'
assert s.mode == 'plan'
def test_follow_up_is_idempotent_on_retry(client):
from app.models.inspection_schedule import InspectionSchedule
user, tmpl, fac = _seed('idem', role='admin')
parent = _completed_inspection(user, tmpl, fac)
first_due = date.today() + timedelta(days=5)
second_due = date.today() + timedelta(days=9)
r1 = _post_follow_up(client, user, parent_inspection_id=parent.id,
due_date=first_due.isoformat())
assert r1.status_code == 201 and r1.get_json()['data']['created'] is True
r2 = _post_follow_up(client, user, parent_inspection_id=parent.id,
due_date=second_due.isoformat(), notes='Updated')
assert r2.status_code == 200
assert r2.get_json()['data']['created'] is False
rows = InspectionSchedule.query.filter_by(parent_inspection_id=parent.id).all()
assert len(rows) == 1
assert rows[0].due_date == second_due
assert rows[0].notes == 'Updated'
def test_follow_up_reschedule_rearms_reminders(client):
from app import db
from app.models.inspection_schedule import InspectionSchedule
user, tmpl, fac = _seed('rearm', role='admin')
parent = _completed_inspection(user, tmpl, fac)
_post_follow_up(client, user, parent_inspection_id=parent.id,
due_date=(date.today() + timedelta(days=2)).isoformat())
s = InspectionSchedule.query.filter_by(parent_inspection_id=parent.id).one()
s.advance_notified = s.due_notified = s.overdue_notified = True
db.session.commit()
_post_follow_up(client, user, parent_inspection_id=parent.id,
due_date=(date.today() + timedelta(days=12)).isoformat())
db.session.expire_all()
s = InspectionSchedule.query.filter_by(parent_inspection_id=parent.id).one()
assert s.advance_notified is False
assert s.due_notified is False
assert s.overdue_notified is False
# ── Validation ───────────────────────────────────────────────────────────────
def test_follow_up_requires_a_completed_parent(client):
from app import db
from app.models.inspection import Inspection
from app.utils.time_utils import now_eastern
user, tmpl, fac = _seed('draft', role='admin')
draft = Inspection(template_id=tmpl.id, facility_id=fac.id,
inspector_id=user.id, inspection_date=now_eastern(),
status='in_progress')
db.session.add(draft)
db.session.commit()
r = _post_follow_up(client, user, parent_inspection_id=draft.id,
due_date=(date.today() + timedelta(days=1)).isoformat())
assert r.status_code == 400
def test_follow_up_rejects_bad_input(client):
user, tmpl, fac = _seed('bad', role='admin')
parent = _completed_inspection(user, tmpl, fac)
ok_due = (date.today() + timedelta(days=1)).isoformat()
# Missing parent id.
assert _post_follow_up(client, user, due_date=ok_due).status_code == 400
# Non-integer parent id.
assert _post_follow_up(client, user, parent_inspection_id='7',
due_date=ok_due).status_code == 400
# Unknown parent.
assert _post_follow_up(client, user, parent_inspection_id=999999,
due_date=ok_due).status_code == 404
# Malformed date.
assert _post_follow_up(client, user, parent_inspection_id=parent.id,
due_date='next tuesday').status_code == 400
# Past date.
past = (date.today() - timedelta(days=1)).isoformat()
assert _post_follow_up(client, user, parent_inspection_id=parent.id,
due_date=past).status_code == 400
def test_follow_up_allows_today(client):
""""Later today" is a legitimate plan; yesterday is not."""
user, tmpl, fac = _seed('today', role='admin')
parent = _completed_inspection(user, tmpl, fac)
r = _post_follow_up(client, user, parent_inspection_id=parent.id,
due_date=date.today().isoformat())
assert r.status_code == 201
def test_follow_up_rejects_auditor(client):
"""Auditor is read-only everywhere else; keep it that way here."""
user, tmpl, fac = _seed('aud', role='auditor')
parent = _completed_inspection(user, tmpl, fac)
r = _post_follow_up(client, user, parent_inspection_id=parent.id,
due_date=(date.today() + timedelta(days=1)).isoformat())
assert r.status_code == 403
def test_inspector_cannot_follow_up_someone_elses_inspection(client):
from app import db
from app.models.user import User
owner, tmpl, fac = _seed('own', role='inspector')
parent = _completed_inspection(owner, tmpl, fac)
other = User(username='other_insp', full_name='Otto', role='inspector',
email='otto@example.com', active=True)
other.set_password('pw-correct1')
db.session.add(other)
db.session.commit()
r = _post_follow_up(client, other, parent_inspection_id=parent.id,
due_date=(date.today() + timedelta(days=1)).isoformat())
assert r.status_code == 403
def test_follow_up_requires_auth(client):
user, tmpl, fac = _seed('noauth', role='admin')
parent = _completed_inspection(user, tmpl, fac)
r = client.post('/api/v1/scheduled-inspections/follow-up',
json={'parent_inspection_id': parent.id,
'due_date': date.today().isoformat()})
assert r.status_code == 401
# ── The link actually propagates ─────────────────────────────────────────────
def test_web_start_inherits_the_parent_link(client):
"""This is the whole point: starting the schedule must produce a LINKED
re-inspection, not an ordinary one."""
from app import db
from app.models.inspection import Inspection
from app.models.inspection_schedule import InspectionSchedule
user, tmpl, fac = _seed('start', role='admin')
parent = _completed_inspection(user, tmpl, fac)
_post_follow_up(client, user, parent_inspection_id=parent.id,
due_date=date.today().isoformat())
sched = InspectionSchedule.query.filter_by(parent_inspection_id=parent.id).one()
client.post('/auth/login', data={'username': user.username,
'password': 'pw-correct1'},
follow_redirects=True)
resp = client.get(f'/inspection-schedules/{sched.id}/start',
follow_redirects=False)
assert resp.status_code == 302
run = (Inspection.query
.filter_by(inspection_schedule_id=sched.id, status='in_progress')
.one())
assert run.parent_inspection_id == parent.id
def test_web_start_resumes_instead_of_duplicating(client):
from app.models.inspection import Inspection
from app.models.inspection_schedule import InspectionSchedule
user, tmpl, fac = _seed('resume', role='admin')
parent = _completed_inspection(user, tmpl, fac)
_post_follow_up(client, user, parent_inspection_id=parent.id,
due_date=date.today().isoformat())
sched = InspectionSchedule.query.filter_by(parent_inspection_id=parent.id).one()
client.post('/auth/login', data={'username': user.username,
'password': 'pw-correct1'},
follow_redirects=True)
client.get(f'/inspection-schedules/{sched.id}/start')
client.get(f'/inspection-schedules/{sched.id}/start')
runs = Inspection.query.filter_by(inspection_schedule_id=sched.id).all()
assert len(runs) == 1
def test_cron_materialiser_inherits_the_parent_link(client):
from app import db
from app.models.inspection import Inspection
from app.models.inspection_schedule import InspectionSchedule
from app.utils.time_utils import now_eastern
user, tmpl, fac = _seed('auto', role='admin')
parent = _completed_inspection(user, tmpl, fac)
sched = InspectionSchedule(
name='Auto follow-up', template_id=tmpl.id, facility_id=fac.id,
inspector_id=user.id, frequency='once', mode='auto', active=True,
next_run_at=now_eastern() - timedelta(hours=1),
parent_inspection_id=parent.id,
)
db.session.add(sched)
db.session.commit()
sid = sched.id
resp = client.post('/inspection-schedules/run', data={'token': 'test-digest'})
assert resp.get_json()['created'] == 1
run = Inspection.query.filter_by(inspection_schedule_id=sid).one()
assert run.parent_inspection_id == parent.id
# 'once' closes the schedule after its single run.
db.session.expire_all()
assert db.session.get(InspectionSchedule, sid).active is False
def test_api_create_infers_parent_from_the_schedule(client):
"""An older iPad build submits the schedule id but no parent. Without the
inference the parent would stay flagged forever."""
from app.models.inspection import Inspection
from app.models.inspection_schedule import InspectionSchedule
user, tmpl, fac = _seed('infer', role='admin')
parent = _completed_inspection(user, tmpl, fac)
_post_follow_up(client, user, parent_inspection_id=parent.id,
due_date=date.today().isoformat())
sched = InspectionSchedule.query.filter_by(parent_inspection_id=parent.id).one()
resp = client.post('/api/v1/inspections', headers=_auth(user), json={
'template_id': tmpl.id, 'facility_id': fac.id, 'status': 'completed',
'scheduled_inspection_id': sched.id, # no parent_inspection_id
'form_data': {'f1': 5},
})
assert resp.status_code in (200, 201)
new_id = resp.get_json()['data']['inspection_id']
run = Inspection.query.get(new_id)
assert run.parent_inspection_id == parent.id
# And the whole point of the link: the parent's flag is cleared.
assert parent.follow_up_required is False
def test_api_explicit_parent_wins_over_the_schedule(client):
from app.models.inspection import Inspection
from app.models.inspection_schedule import InspectionSchedule
user, tmpl, fac = _seed('explicit', role='admin')
parent_a = _completed_inspection(user, tmpl, fac)
parent_b = _completed_inspection(user, tmpl, fac)
_post_follow_up(client, user, parent_inspection_id=parent_a.id,
due_date=date.today().isoformat())
sched = InspectionSchedule.query.filter_by(parent_inspection_id=parent_a.id).one()
resp = client.post('/api/v1/inspections', headers=_auth(user), json={
'template_id': tmpl.id, 'facility_id': fac.id, 'status': 'completed',
'scheduled_inspection_id': sched.id,
'parent_inspection_id': parent_b.id,
'form_data': {'f1': 5},
})
new_id = resp.get_json()['data']['inspection_id']
assert Inspection.query.get(new_id).parent_inspection_id == parent_b.id
def test_ordinary_schedule_produces_no_parent_link(client):
"""Every pre-phase48 row is NULL here and must stay that way."""
from app import db
from app.models.inspection import Inspection
from app.models.inspection_schedule import InspectionSchedule
from app.utils.time_utils import now_eastern
user, tmpl, fac = _seed('plain', role='admin')
sched = InspectionSchedule(
name='Plain weekly', template_id=tmpl.id, facility_id=fac.id,
inspector_id=user.id, frequency='weekly', mode='auto', active=True,
next_run_at=now_eastern() - timedelta(hours=1),
)
db.session.add(sched)
db.session.commit()
assert sched.is_follow_up is False
client.post('/inspection-schedules/run', data={'token': 'test-digest'})
run = Inspection.query.filter_by(inspection_schedule_id=sched.id).one()
assert run.parent_inspection_id is None