Files
LT_Janitorial_Quality_Control/app/api/scheduled.py
T

247 lines
9.9 KiB
Python

"""
app/api/scheduled.py
--------------------
Mobile API endpoint for planned/recurring inspection assignments (phase36).
GET /api/v1/scheduled-inspections
Returns ACTIVE scheduled inspections the caller is responsible for.
- inspector : only schedules where inspector_id == the caller
- admin / director / project_manager : all active schedules
Powers the "Scheduled" section on the iPad Dashboard and My Inspections
lists. The iPad taps "Start", which opens the normal new-inspection flow
with the facility + template preselected (client-side); the schedule
lifecycle (fulfil / roll-forward) continues to be driven by the web app.
A ScheduledInspection is a PLAN, not an inspection — see
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__)
bp = Blueprint('api_scheduled', __name__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
'project_manager', 'auditor'}
def _scheduled_payload(s):
"""Serialise a ScheduledInspection to the dict returned in list responses."""
return {
'id': s.id,
'facility_id': s.facility_id,
'facility_name': s.facility.name if s.facility else None,
'template_id': s.template_id,
'template_name': s.template.name if s.template else None,
'inspector_id': s.inspector_id,
'frequency': s.frequency,
'frequency_label': s.frequency_label,
# phase43 recurrence detail. `recurrence_label` is the display string
# ("Weekly · Mon, Wed, Fri"); the raw fields let the iPad render its own.
'recurrence_label': s.recurrence_label,
'weekdays': s.weekday_list,
'month_mode': s.month_mode,
'day_of_month': s.day_of_month,
'nth_week': s.nth_week,
'nth_weekday': s.nth_weekday,
'next_due_date': s.next_due_date.isoformat() if s.next_due_date else None,
# phase44. Additive: the iPad decodes explicit CodingKeys, so a build
# 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(),
'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,
# phase47 — receipt acknowledgement. Read-only here: this collection is
# read-only per CLAUDE.md rule 77, so confirming happens on the web. The
# iPad can display "confirmed" state from this timestamp (NULL = the
# assigned inspector has not confirmed receipt yet).
'acknowledged_at': s.acknowledged_at.isoformat() if s.acknowledged_at else None,
}
# ── List Scheduled Inspections ────────────────────────────────────────────────
@bp.route('/scheduled-inspections', methods=['GET'])
@jwt_required
def list_scheduled():
"""
Return active scheduled inspections for the authenticated user.
Query parameters
----------------
limit int Default 100, max 200.
offset int Default 0.
Response 200
------------
{
"ok": true,
"data": {
"scheduled": [...],
"total": 3,
"limit": 100,
"offset": 0
}
}
"""
user = g.api_user
if user.role not in _ALLOWED_ROLES:
return api_error('Access denied', 403)
limit = min(int(request.args.get('limit', 100)), 200)
offset = max(int(request.args.get('offset', 0)), 0)
query = ScheduledInspection.query.filter(ScheduledInspection.active.is_(True))
if user.is_inspector:
# Inspectors only see schedules assigned directly to them.
query = query.filter(ScheduledInspection.inspector_id == user.id)
total = query.count()
rows = (
query
.order_by(ScheduledInspection.next_due_date.asc())
.offset(offset)
.limit(limit)
.all()
)
payload = [_scheduled_payload(s) for s in rows]
logger.info('API SCHEDULED | list | user=%s | count=%d | total=%d',
user.username, len(payload), total)
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', 'external_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.is_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)