Files
JQC_multi_tenant/app/routes/inspection_schedules.py
T
2026-08-19 16:26:32 -04:00

1265 lines
58 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
app/routes/inspection_schedules.py
-----------------------------------
CRUD management for recurring InspectionSchedule configs + a cron-triggered
materialisation/reminder endpoint (phase34, extended phase43).
Two modes per schedule (phase43):
auto — cron materialises an in_progress Inspection at next_run_at and notifies
the inspector. This is phase34's behaviour and remains the default.
plan — nothing is materialised; the assigned inspector clicks "Start" on the
schedules page, which creates the Inspection linked back to the
schedule. Reminders fire the day before, on the due date, and once
overdue (to admin/director). Completing it rolls the schedule forward.
The /run cron endpoint does both: it materialises due `auto` schedules AND
dispatches reminders for `plan` schedules, so the existing crontab line needs no
change.
Management is admin / director / project_manager (@project_manager_required),
mirroring who may start inspections. The /run route is token-protected with the
same DIGEST_SECRET used by the other cron endpoints.
Cron example
------------
# Every day at 06:00 — materialises all schedules that have come due.
0 6 * * * curl -s -X POST https://yourdomain.com/inspection-schedules/run \
-d "token=YOUR_DIGEST_SECRET"
"""
import logging
from datetime import datetime, timedelta
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from flask import (Blueprint, render_template, redirect, url_for, flash,
request, jsonify, current_app, abort)
from flask_login import login_required, current_user
from app import db, csrf
from app.models.inspection_schedule import (InspectionSchedule,
FREQUENCY_CHOICES,
MONTH_MODE_DAY, MONTH_MODE_NTH,
DEFAULT_RUN_HOUR)
from app.models.inspection import Inspection, InspectionTemplate
from app.models.facility import Facility, Area
from app.models.project import Project
from app.models.user import User
from functools import wraps
from app.utils.decorators import project_manager_required
from app.utils.scope import get_customer_scope
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
from app.utils.time_utils import now_eastern
from app.utils.notifications import notify
from app.models.notification import EVENT_INSPECTION_SCHEDULED
logger = logging.getLogger(__name__)
bp = Blueprint('inspection_schedules', __name__, url_prefix='/inspection-schedules')
_FREQUENCIES = FREQUENCY_CHOICES
_MODES = ('auto', 'plan')
_MONTH_MODES = (MONTH_MODE_DAY, MONTH_MODE_NTH)
# ── Helpers ───────────────────────────────────────────────────────────────────
def _compute_next_run(frequency: str, from_dt: datetime = None) -> datetime:
"""Return the next due datetime for a given cadence, at 06:00 local.
Plain interval maths with no day-of-week / day-of-month detail. Used only to
seed a schedule's FIRST occurrence when the manager does not pick a start
date; every subsequent roll-forward goes through
InspectionSchedule.fulfill(), which honours the phase46 recurrence columns.
Monthly and longer cadences advance by whole days rather than calendar
months here, which is predictable and never raises the
datetime.replace(month=13) ValueError. Kept as-is for the original four
frequencies so no existing schedule's first-run maths changes.
"""
now = from_dt or now_eastern()
if frequency == 'once':
base = now
elif frequency == 'daily':
base = now + timedelta(days=1)
elif frequency == 'weekly':
base = now + timedelta(weeks=1)
elif frequency == 'monthly':
base = now + timedelta(days=30)
elif frequency == 'bi-annually':
base = now + timedelta(days=182)
elif frequency == 'annually':
base = now + timedelta(days=365)
else: # quarterly
base = now + timedelta(days=90)
return base.replace(hour=DEFAULT_RUN_HOUR, minute=0, second=0, microsecond=0)
def _parse_date(raw):
"""Parse an ISO date from a form field. Returns None for blank/invalid."""
raw = (raw or '').strip()
if not raw:
return None
try:
return datetime.strptime(raw, '%Y-%m-%d').date()
except ValueError:
return None
def _recurrence_errors(form, frequency):
"""Validation messages for the recurrence block of *frequency* (phase46).
Only the block matching the chosen frequency is checked; the others are
ignored here and cleared on save by _apply_recurrence().
"""
errors = []
if frequency == 'weekly':
if not form.getlist('weekdays'):
errors.append('Pick at least one day of the week.')
elif frequency in ('monthly', 'quarterly', 'bi-annually', 'annually'):
month_mode = form.get('month_mode') or MONTH_MODE_DAY
if month_mode not in _MONTH_MODES:
errors.append('Invalid monthly rule.')
elif month_mode == MONTH_MODE_NTH:
if not form.get('nth_week', type=int) or form.get('nth_weekday', type=int) is None:
errors.append('Choose which weekday of the month.')
else:
dom = form.get('day_of_month', type=int)
if not dom or not 1 <= dom <= 31:
errors.append('Enter a day of the month (131).')
return errors
def _apply_recurrence(sched, form, frequency, due_date):
"""Copy the recurrence block for *frequency* onto *sched*, clear the blocks
that no longer apply, set the end date, then snap the due date onto the rule.
Keeping the unused columns NULL means recurrence_label and the date maths
never read stale settings after a frequency change. Caller commits.
"""
sched.frequency = frequency
if frequency == 'weekly':
sched.set_weekdays([int(v) for v in form.getlist('weekdays') if v.lstrip('-').isdigit()])
else:
sched.weekdays = None
if frequency in ('monthly', 'quarterly', 'bi-annually', 'annually'):
sched.month_mode = form.get('month_mode') or MONTH_MODE_DAY
if sched.month_mode == MONTH_MODE_NTH:
sched.day_of_month = None
sched.nth_week = form.get('nth_week', type=int)
sched.nth_weekday = form.get('nth_weekday', type=int)
else:
sched.day_of_month = form.get('day_of_month', type=int)
sched.nth_week = None
sched.nth_weekday = None
else:
sched.month_mode = sched.day_of_month = None
sched.nth_week = sched.nth_weekday = None
# End date (phase47) — a boundary, not a cadence setting. A one-time
# schedule has none: it ends by deactivating when it is completed.
sched.end_date = _parse_date(form.get('end_date')) if frequency != 'once' else None
# Snap the picked date forward onto the first matching occurrence.
sched.set_next_run_date(sched.align_due_date(due_date))
def _end_date_errors(sched, form, frequency):
"""End-date validation (phase47), run AFTER _apply_recurrence().
Two separate rejections:
* an end date on a one-time schedule — meaningless, and silently dropping
it would hide the mistake;
* an aligned first occurrence past the end date. align_due_date() can push
the picked date forward onto the rule (pick a Tuesday for a Mon/Wed/Fri
schedule and the first occurrence is Wednesday), so an end date that
looked valid against the picked date can still leave the schedule with
no occurrence it is ever allowed to run.
"""
errors = []
raw_end = _parse_date(form.get('end_date'))
if raw_end and frequency == 'once':
errors.append('A one-time schedule has no end date — '
'it closes when it is completed.')
elif raw_end and sched.due_date and not sched.is_within_end_date(sched.due_date):
errors.append(f'With this recurrence the first occurrence falls on '
f'{sched.due_date:%b %d, %Y}, after the end date.')
return errors
# ── Email "Confirm receipt" one-click token (phase50) ─────────────────────────
# A signed, STATELESS token — no DB column, nothing to clean up — lets the
# assigned inspector confirm receipt straight from the assignment email without
# logging in, the same login-free pattern the public QR scan pages use.
#
# The token binds the schedule id to the inspector id, so reassigning a schedule
# to someone else silently invalidates any link already emailed to the previous
# assignee. That check happens at redemption, not issuance, which is what makes
# a stateless token safe here.
_ACK_SALT = 'inspection-schedule-ack'
_ACK_MAX_AGE = 60 * 60 * 24 * 30 # 30 days — a link older than this is expired
def _ack_serializer():
return URLSafeTimedSerializer(current_app.config['SECRET_KEY'], salt=_ACK_SALT)
def _make_ack_token(schedule):
"""Signed token embedding the schedule id + the assigned inspector id."""
return _ack_serializer().dumps({'sid': schedule.id, 'iid': schedule.inspector_id})
def _confirm_action(schedule):
"""`extra_action` dict for the email "Confirm receipt" button, or None.
None when there is nothing to confirm — no inspector, already acknowledged,
or an auto-mode schedule (which materialises its own inspection, so there is
no request to receive). Requires a request context for the external URL.
Reused by the assignment email AND the advance/due reminders, so an inspector
who missed the first email can still confirm from whichever one reaches them.
"""
if not schedule.inspector_id or schedule.is_acknowledged or schedule.mode != 'plan':
return None
return {
'label': 'Confirm receipt',
'url': url_for('inspection_schedules.confirm_email',
token=_make_ack_token(schedule), _external=True),
}
def _notify_creator_acknowledged(schedule):
"""Tell the schedule's creator that the inspector confirmed receipt.
No-op when there is no creator, the creator is inactive, or the creator IS
the inspector (self-assigned — they do not need telling). Caller commits.
"""
creator = schedule.creator
if not creator or not creator.active or creator.id == schedule.inspector_id:
return
fac = schedule.facility.name if schedule.facility else '—'
tpl = schedule.template.name if schedule.template else '—'
who = schedule.inspector.display_name if schedule.inspector else 'The inspector'
due = schedule.next_run_at.strftime('%b %d, %Y') if schedule.next_run_at else 'soon'
notify(
creator,
title = f'Inspector confirmed receipt — {fac}',
body = (f'{who} confirmed receipt of the "{tpl}" scheduled '
f'inspection at {fac} ({schedule.recurrence_label.lower()}), '
f'due {due}.'),
link = url_for('inspection_schedules.index'),
event_type = EVENT_INSPECTION_SCHEDULED,
send_email = True,
)
def _notify_assignee(schedule: InspectionSchedule, reassigned: bool = False):
"""Tell the assigned inspector a schedule was assigned (or reassigned) to them.
No-op when there is no active inspector. Caller commits. (phase43)
"""
inspector = schedule.inspector
if not inspector or not inspector.active:
return
fac = schedule.facility.name if schedule.facility else '—'
tpl = schedule.template.name if schedule.template else '—'
verb = 'reassigned to you' if reassigned else 'assigned to you'
due = schedule.next_run_at.strftime('%b %d, %Y') if schedule.next_run_at else 'soon'
notify(
inspector,
title = f'Scheduled inspection {verb}{fac}',
body = (f'A "{tpl}" inspection at {fac} has been {verb} '
f'({schedule.recurrence_label.lower()}), due {due}.'
+ (' Please confirm you received this request.'
if _confirm_action(schedule) else '')),
link = url_for('inspection_schedules.index'),
event_type = EVENT_INSPECTION_SCHEDULED,
send_email = True,
extra_action = _confirm_action(schedule),
)
def _materialise(schedule: InspectionSchedule, when: datetime) -> Inspection:
"""Create an in_progress Inspection from a schedule and notify the inspector.
Does NOT commit — the caller commits after advancing schedule bookkeeping so
the new inspection and the schedule update land in one transaction.
"""
inspection = Inspection(
template_id = schedule.template_id,
facility_id = schedule.facility_id,
area_id = schedule.area_id,
inspector_id = schedule.inspector_id,
inspection_date = when,
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
inspector = schedule.inspector
if inspector is not None:
fac_name = schedule.facility.name if schedule.facility else 'a facility'
notify(
inspector,
title='Scheduled inspection due',
body=(f'A recurring inspection "{schedule.name}" at {fac_name} '
f'is due. Open it from your inspections list to begin.'),
link=url_for('inspections.execute', inspection_id=inspection.id),
inspection_id=inspection.id,
event_type=EVENT_INSPECTION_SCHEDULED,
)
return inspection
# ── Who may plan an inspection ───────────────────────────────────────────────
#: Our own staff who plan inspections. Customer Directors are added on top by
#: schedule_manager_required — they plan work for their OWN facilities only.
_STAFF_SCHEDULERS = ('admin', 'director', 'project_manager', 'auditor')
def _is_customer_director(user):
"""True only for the portal customer role.
Equality on purpose (rule 89): a Customer Inspector PERFORMS scheduled
inspections, they do not plan them, and they are scoped by
InspectorAssignment rather than CustomerAssignment. Widening this to
User.CUSTOMER_ROLES would hand them a planning screen scoped by the wrong
table — i.e. no facilities at all.
"""
return getattr(user, 'role', None) == 'customer'
def schedule_manager_required(f):
"""Who may create / edit / delete a scheduled inspection.
Our staff (_STAFF_SCHEDULERS) plus **Customer Directors**, who schedule
inspections for the facilities they are assigned. Every choice list and
every POST is narrowed to their own contracts — see _form_choices(),
_scope_errors() and _schedule_in_scope().
"""
@wraps(f)
def wrapper(*args, **kwargs):
if not current_user.is_authenticated:
abort(403)
if current_user.role in _STAFF_SCHEDULERS or _is_customer_director(current_user):
return f(*args, **kwargs)
flash('You do not have permission to manage scheduled inspections.', 'danger')
return redirect(url_for('dashboard.index'))
return wrapper
def _customer_facility_ids():
"""Facility ids the current Customer Director may schedule against."""
return set(get_customer_scope(current_user) or [])
def _customer_project_ids():
"""Contract ids behind those facilities.
Derived from the facilities rather than straight off CustomerAssignment, so
a facility-level assignment resolves to its owning contract and the
contract selector still lines up with the facilities on offer.
"""
fids = _customer_facility_ids()
if not fids:
return set()
return {
f.project_id
for f in Facility.query.filter(Facility.id.in_(fids)).all()
if f.project_id
}
def _schedule_in_scope(sched):
"""May the current user act on this schedule?
Staff: any. Customer Director: only schedules at a facility they are
assigned — checked on edit and delete so a hand-typed id cannot reach
another customer's schedule.
"""
if not _is_customer_director(current_user):
return True
return sched.facility_id in _customer_facility_ids()
def _scope_errors(template_id, facility_id, inspector_id):
"""Validate a submitted schedule against the actor's scope and phase55.
This route builds its form by hand (no WTForms SelectField), so narrowing
the choice lists is NOT the validation — a crafted POST would sail past it.
Every id is therefore re-checked here:
* Customer Director — facility and inspector must belong to their own
contracts, otherwise they could schedule work at, or assign it to,
another customer.
* Everyone — the chosen form must be available on the chosen facility's
contract (phase55). Without this a manager could schedule one
customer's bespoke form against another customer's facility, and the
mismatch would only surface when the inspector opened it.
"""
errors = []
facility = db.session.get(Facility, facility_id) if facility_id else None
if _is_customer_director(current_user):
fids = _customer_facility_ids()
if not facility_id or facility_id not in fids:
logger.warning(
'SCHED INSP | out-of-scope facility blocked | user=%s | facility_id=%s',
current_user.username, facility_id)
errors.append('That facility is not one of yours. '
'Choose a facility from your contracts.')
# The assignee must hold an InspectorAssignment on the chosen facility's
# contract — the same rule that built the dropdown. Enforced for EVERY
# role: the dropdown is a UI hint, this is the boundary, and a stale page
# (or a crafted POST) must not slip an out-of-contract assignee through.
if inspector_id:
allowed = {u.id for u in _assignable_inspectors(
facility.project_id if facility else None)}
if inspector_id not in allowed:
logger.warning(
'SCHED INSP | out-of-contract inspector blocked | user=%s | '
'inspector_id=%s | facility_id=%s',
current_user.username, inspector_id, facility_id)
who = db.session.get(User, inspector_id)
name = who.display_name if who else 'That person'
contract = (facility.project.name
if facility is not None and facility.project else None)
if contract:
errors.append(
f'{name} is not assigned to {contract}. Choose an inspector '
f'who works on that contract, or assign them to it first '
f'(Admin \u2192 Users \u2192 Assign Contracts).')
else:
# Only reachable for a Customer Director (staff get the
# fallback pool above), whose scope IS the contract.
errors.append(
'That inspector does not work on your contracts.')
template = db.session.get(InspectionTemplate, template_id) if template_id else None
if template is not None and facility is not None:
if not template.available_for_project(facility.project_id):
contract = facility.project.name if facility.project else "this facility's contract"
errors.append(f'"{template.name}" is not available on {contract}. '
f'Choose a form attached to that contract, or a shared form.')
return errors
def _inspectors_for_project(project_id):
"""Inspectors assignable to a schedule on *project_id*.
Two rules, both deliberate:
* **Inspector roles only.** admin / director / project_manager are NOT
offered even though they can open any inspection. A schedule names the
person who must go and do the work, and a manager who intends to do it
themselves holds an InspectorAssignment like anyone else. Offering the
whole staff list made the dropdown a roster of the company and invited
assigning work to someone who never inspects.
* **Scoped to the contract**, via the same InspectorAssignment rows
get_inspector_scope() reads — so whoever is offered can actually open
what they are given, and one customer's inspector can never be handed
another customer's building (rule 93, the flag-issue leak, in the
scheduling form).
Returns [] for a facility with no contract: fail-closed, and the caller
turns that into an actionable message rather than an empty dropdown.
A Customer Director asking for a contract that is not theirs also gets [] —
the contract selector cannot name one, but the AJAX endpoint takes an id
from the client.
"""
if not project_id:
return []
if _is_customer_director(current_user) and project_id not in _customer_project_ids():
logger.warning(
'SCHED INSP | out-of-scope inspector list request | user=%s | project_id=%s',
current_user.username, project_id)
return []
from app.models.inspector_assignment import InspectorAssignment
rows = (User.query
.join(InspectorAssignment, InspectorAssignment.user_id == User.id)
.filter(User.role.in_(User.INSPECTOR_ROLES),
User.active == True,
InspectorAssignment.project_id == project_id)
.order_by(User.full_name, User.username)
.all())
seen, uniq = set(), []
for u in rows:
# The join can repeat a user across assignment rows.
if u.id not in seen:
seen.add(u.id)
uniq.append(u)
return uniq
def _all_active_inspectors():
"""Every active inspector, either role. Never managers (see below)."""
return (User.query
.filter(User.role.in_(User.INSPECTOR_ROLES), User.active == True)
.order_by(User.full_name, User.username)
.all())
def _assignable_inspectors(project_id):
"""Who may be assigned a schedule at a facility on *project_id*.
Contract-scoped, with ONE fallback: when the contract has no inspectors
assigned — or the facility is on no contract at all — our own staff get the
full active inspector pool instead of an empty list.
The fallback exists because the strict rule alone makes scheduling
impossible until someone wires up InspectorAssignment rows, and it fails
with an error about a screen the person may not have thought about. A
facility legitimately has no contract (`facilities.project_id` is nullable),
and that must not become "no inspections can be planned here".
It is deliberately NOT offered to a Customer Director: for them the contract
boundary is a confidentiality boundary, and widening it on a setup gap would
hand one customer another customer's inspectors — the exact leak this
scoping closes (rule 93). They get the empty list and an actionable message.
Managers (admin / director / project_manager) are never in either list. A
schedule names whoever must go and do the work; a manager who intends to do
it holds a contract assignment like anyone else.
"""
scoped = _inspectors_for_project(project_id)
if scoped or _is_customer_director(current_user):
return scoped
logger.info(
'SCHED INSP | no inspectors assigned to contract %s — falling back to '
'the full inspector pool for %s', project_id, current_user.username)
return _all_active_inspectors()
# ── AJAX: inspectors working on a contract ──────────────────────────────────
@bp.route('/inspectors-for-contract/<int:project_id>')
@login_required
@schedule_manager_required
def inspectors_for_contract(project_id):
"""Assignable inspectors for one contract — powers the form's cascade.
Same list the POST validation uses, so the two cannot drift. Returns an
empty list rather than 403 for a contract the caller may not see, so it
does not confirm whether that contract exists.
"""
return jsonify([
{'id': u.id,
'name': u.display_name + (' (Customer)' if u.is_external_inspector else '')}
for u in _assignable_inspectors(project_id)
])
# ── CRUD ──────────────────────────────────────────────────────────────────────
@bp.route('/')
@login_required
def index():
"""Schedule list.
phase43: no longer @project_manager_required — an inspector must be able to
see and Start their own plan-mode schedules. Inspectors see ONLY their own;
customers are barred; managers see everything, exactly as before. All
mutating routes below keep @project_manager_required.
"""
# Customer Directors now plan inspections for their own facilities, so they
# reach this list too — narrowed below. Customer Inspectors already saw it
# (they are inspectors) and keep their own-assignments-only view.
# Two tabs (phase51): Pending = schedules still producing occurrences
# (active); Completed = closed ones — fulfilled one-times, recurring
# schedules past their end date, and manually paused ones. The partition is
# exhaustive and non-overlapping on `active`, so every schedule appears in
# exactly one tab and none can be lost; the in-row Status badge
# (Active / Ended / Paused) disambiguates the closed ones.
#
# Before this the list was a single table sorted active-first, which meant a
# tenant with years of one-time follow-ups buried the handful of live
# schedules an inspector actually needed to act on.
tab = request.args.get('tab', 'pending')
if tab not in ('pending', 'completed'):
tab = 'pending'
base = InspectionSchedule.query
# Inspectors see only their own assignments; managers see everything.
if current_user.is_inspector:
base = base.filter(InspectionSchedule.inspector_id == current_user.id)
elif _is_customer_director(current_user):
# Only schedules at facilities they are assigned. An empty scope must
# match nothing rather than everything — filter(False), not a skipped
# filter (rule 57's failure mode).
fids = _customer_facility_ids()
base = (base.filter(InspectionSchedule.facility_id.in_(fids))
if fids else base.filter(False))
# Counts are computed on the same scoped query, so the badges match what the
# viewer can actually open.
pending_count = base.filter(InspectionSchedule.active.is_(True)).count()
completed_count = base.filter(InspectionSchedule.active.is_(False)).count()
if tab == 'pending':
schedules = (base.filter(InspectionSchedule.active.is_(True))
.order_by(InspectionSchedule.next_run_at.asc(),
InspectionSchedule.name).all())
else:
# Most recently completed first. A schedule switched off before it ever
# ran has a NULL last_completed_at and sorts last under DESC.
schedules = (base.filter(InspectionSchedule.active.is_(False))
.order_by(InspectionSchedule.last_completed_at.desc(),
InspectionSchedule.next_run_at.desc(),
InspectionSchedule.name).all())
now = now_eastern()
return render_template('inspection_schedules/index.html',
schedules=schedules, now=now, today=now.date(),
tab=tab, pending_count=pending_count,
completed_count=completed_count)
def _active_contracts():
"""Contracts offered in the UI-only contract selector on the form.
Narrowed to a Customer Director's own contracts so the selector cannot even
name another customer's contract. The selector is not submitted — the
facility is what the route validates (rule 61) — so this is presentation,
with _scope_errors() doing the enforcing.
"""
q = Project.query.filter_by(active=True)
if _is_customer_director(current_user):
pids = _customer_project_ids()
q = q.filter(Project.id.in_(pids)) if pids else q.filter(False)
return q.order_by(Project.name).all()
def _project_for_facility(facility_id):
"""The contract a facility belongs to — seeds the contract selector so an
edit, or a re-render after a validation error, comes back with both
dropdowns as the user left them."""
if not facility_id:
return None
fac = db.session.get(Facility, facility_id)
return fac.project_id if fac else None
def _form_choices(project_id=None):
"""Lists offered on the schedule form, narrowed to the actor's scope.
For a Customer Director every list is limited to their own contracts —
including the FORM list, so another customer's bespoke form names never
appear (phase55 / rule 96). The lists are a UI convenience only; the POST
is re-validated by _scope_errors().
"""
customer_scoped = _is_customer_director(current_user)
fac_q = Facility.query.filter_by(active=True)
if customer_scoped:
fids = _customer_facility_ids()
fac_q = fac_q.filter(Facility.id.in_(fids)) if fids else fac_q.filter(False)
facilities = fac_q.order_by(Facility.name).all()
if customer_scoped:
# Shared forms plus those attached to their contracts (phase55) — the
# same union the mobile API builds.
seen, templates = set(), []
for pid in list(_customer_project_ids()) + [None]:
for t in InspectionTemplate.available_query(pid).all():
if t.id not in seen:
seen.add(t.id)
templates.append(t)
templates.sort(key=lambda t: (t.name or '').lower())
else:
templates = (InspectionTemplate.query.filter_by(active=True)
.order_by(InspectionTemplate.name).all())
# Inspectors are scoped to the CONTRACT, so the list is empty until one is
# chosen; the form's JS refills it from inspectors_for_contract on every
# contract change. On edit and on a re-render after a validation error the
# contract is known here, so the saved assignee is present in the markup
# before that call returns.
inspectors = _assignable_inspectors(project_id)
return templates, facilities, inspectors
@bp.route('/new', methods=['GET', 'POST'])
@login_required
@schedule_manager_required
def create():
projects = _active_contracts()
# On a POST the chosen facility names the contract, so the inspector list
# can be rebuilt for the re-render; on a fresh GET there is none yet.
selected_project_id = _project_for_facility(
request.form.get('facility_id', type=int)) if request.method == 'POST' else None
templates, facilities, inspectors = _form_choices(selected_project_id)
if request.method == 'POST':
name = request.form.get('name', '').strip()
template_id = request.form.get('template_id', type=int)
facility_id = request.form.get('facility_id', type=int)
area_id = request.form.get('area_id', type=int) or None
inspector_id = request.form.get('inspector_id', type=int)
frequency = request.form.get('frequency', 'weekly')
mode = request.form.get('mode', 'auto')
notes = request.form.get('notes', '').strip() or None
errors = []
if not name:
errors.append('A schedule name is required.')
if not template_id or db.session.get(InspectionTemplate, template_id) is None:
errors.append('Please choose a valid template.')
if not facility_id or db.session.get(Facility, facility_id) is None:
errors.append('Please choose a valid facility.')
if not inspector_id or db.session.get(User, inspector_id) is None:
errors.append('Please choose a valid inspector.')
if frequency not in _FREQUENCIES:
errors.append('Invalid frequency.')
if mode not in _MODES:
errors.append('Invalid mode.')
errors.extend(_recurrence_errors(request.form, frequency))
errors.extend(_scope_errors(template_id, facility_id, inspector_id))
if errors:
for e in errors:
flash(e, 'warning')
return render_template('inspection_schedules/form.html',
projects=projects,
selected_project_id=selected_project_id,
templates=templates, facilities=facilities,
inspectors=inspectors, frequencies=_FREQUENCIES,
frequency_labels=InspectionSchedule.FREQUENCY_LABELS,
title='New Inspection Schedule')
schedule = InspectionSchedule(
name = name,
template_id = template_id,
facility_id = facility_id,
area_id = area_id,
inspector_id = inspector_id,
frequency = frequency,
mode = mode,
notes = notes,
active = True,
created_by = current_user.id,
created_at = now_eastern(),
# Seeded here so run_time has a time-of-day to preserve;
# _apply_recurrence() below rewrites the DATE part.
next_run_at = _compute_next_run(frequency),
)
# Blank start date keeps the pre-phase46 behaviour exactly: the first
# occurrence lands one interval from now. A picked date wins.
start_date = _parse_date(request.form.get('next_due_date')) \
or schedule.next_run_at.date()
_apply_recurrence(schedule, request.form, frequency, start_date)
end_errors = _end_date_errors(schedule, request.form, frequency)
if end_errors:
# schedule was never added to the session — nothing to roll back.
for e in end_errors:
flash(e, 'warning')
return render_template('inspection_schedules/form.html',
projects=projects,
selected_project_id=selected_project_id,
templates=templates, facilities=facilities,
inspectors=inspectors, frequencies=_FREQUENCIES,
frequency_labels=InspectionSchedule.FREQUENCY_LABELS,
title='New Inspection Schedule')
db.session.add(schedule)
db.session.commit()
log_action(ACTION_CREATE, 'InspectionSchedule', schedule.id, schedule.name,
f'frequency={schedule.recurrence_label}; mode={mode}; '
f'due={schedule.due_date}; end={schedule.end_date or "—"}; '
f'template_id={template_id}; facility_id={facility_id}')
# phase43: tell the inspector it's theirs (plan mode has no materialised
# inspection to announce itself).
_notify_assignee(schedule)
db.session.commit()
flash(f'Inspection schedule "{schedule.name}" created.', 'success')
return redirect(url_for('inspection_schedules.index'))
return render_template('inspection_schedules/form.html',
projects=projects,
selected_project_id=selected_project_id,
templates=templates, facilities=facilities,
inspectors=inspectors, frequencies=_FREQUENCIES,
frequency_labels=InspectionSchedule.FREQUENCY_LABELS,
title='New Inspection Schedule')
@bp.route('/<int:schedule_id>/edit', methods=['GET', 'POST'])
@login_required
@schedule_manager_required
def edit(schedule_id):
schedule = db.session.get(InspectionSchedule, schedule_id)
if schedule is None:
abort(404)
if not _schedule_in_scope(schedule):
abort(403)
projects = _active_contracts()
# A POST may be moving the schedule to another contract; the re-render must
# show that contract's inspectors, not the saved one's.
selected_project_id = (_project_for_facility(request.form.get('facility_id', type=int))
if request.method == 'POST'
else None) or _project_for_facility(schedule.facility_id)
templates, facilities, inspectors = _form_choices(selected_project_id)
if request.method == 'POST':
old_inspector_id = schedule.inspector_id
schedule.name = request.form.get('name', '').strip() or schedule.name
template_id = request.form.get('template_id', type=int)
facility_id = request.form.get('facility_id', type=int)
inspector_id = request.form.get('inspector_id', type=int)
frequency = request.form.get('frequency', schedule.frequency)
if frequency not in _FREQUENCIES:
frequency = schedule.frequency
errors = _recurrence_errors(request.form, frequency)
errors.extend(_scope_errors(template_id or schedule.template_id,
facility_id or schedule.facility_id,
inspector_id))
if errors:
db.session.rollback()
for e in errors:
flash(e, 'warning')
return render_template('inspection_schedules/form.html',
projects=projects,
selected_project_id=selected_project_id,
schedule=schedule, templates=templates,
facilities=facilities, inspectors=inspectors,
frequencies=_FREQUENCIES,
frequency_labels=InspectionSchedule.FREQUENCY_LABELS,
title='Edit Inspection Schedule')
if template_id and db.session.get(InspectionTemplate, template_id):
schedule.template_id = template_id
if facility_id and db.session.get(Facility, facility_id):
schedule.facility_id = facility_id
if inspector_id and db.session.get(User, inspector_id):
# Reassigning to a DIFFERENT inspector invalidates any prior
# confirmation — the new assignee has acknowledged nothing (phase50).
# Comparison before assignment, and only on a real change, so an
# unrelated save does not silently re-open a confirmed assignment.
if inspector_id != schedule.inspector_id:
schedule.acknowledged_at = None
schedule.inspector_id = inspector_id
schedule.area_id = request.form.get('area_id', type=int) or None
mode = request.form.get('mode', schedule.mode)
if mode in _MODES:
schedule.mode = mode
schedule.notes = request.form.get('notes', '').strip() or None
schedule.active = bool(request.form.get('active'))
# ── Due date (phase46 root-cause fix) ─────────────────────────────
# This route used to do `next_run_at = _compute_next_run(frequency)`
# unconditionally, recomputing the due date from *now* on every save.
# Renaming a schedule or editing its notes therefore silently threw
# away the manager's chosen due date and reset every reminder — which
# with an end date and a day-of-week rule would also skip occurrences.
# The due date is now only rewritten when the manager picks a new one,
# or when the recurrence rule no longer fits the existing one (in which
# case align_due_date() snaps it forward to the nearest valid date).
old_due = schedule.due_date
target = (_parse_date(request.form.get('next_due_date'))
or old_due
or _compute_next_run(frequency).date())
_apply_recurrence(schedule, request.form, frequency, target)
end_errors = _end_date_errors(schedule, request.form, frequency)
if end_errors:
# schedule is persistent and already mutated — discard the pending
# changes before re-rendering so nothing leaks out on next flush.
db.session.rollback()
for e in end_errors:
flash(e, 'warning')
return render_template('inspection_schedules/form.html',
projects=projects,
selected_project_id=selected_project_id,
schedule=schedule, templates=templates,
facilities=facilities, inspectors=inspectors,
frequencies=_FREQUENCIES,
frequency_labels=InspectionSchedule.FREQUENCY_LABELS,
title='Edit Inspection Schedule')
# New occurrence -> the previous occurrence's reminders no longer apply.
# Unchanged due date -> keep the flags, or every unrelated edit would
# re-send the advance/due reminder the inspector already received.
if schedule.due_date != old_due:
schedule.advance_notified = False
schedule.due_notified = False
schedule.overdue_notified = False
db.session.commit()
log_action(ACTION_UPDATE, 'InspectionSchedule', schedule.id, schedule.name,
f'frequency={schedule.recurrence_label}; mode={schedule.mode}; '
f'due={schedule.due_date}; end={schedule.end_date or "—"}; '
f'active={schedule.active}')
# phase43: notify on (re)assignment to a different inspector.
if schedule.active and schedule.inspector_id and schedule.inspector_id != old_inspector_id:
_notify_assignee(schedule, reassigned=True)
db.session.commit()
flash(f'Inspection schedule "{schedule.name}" updated.', 'success')
return redirect(url_for('inspection_schedules.index'))
return render_template('inspection_schedules/form.html',
projects=projects,
selected_project_id=selected_project_id, schedule=schedule,
templates=templates, facilities=facilities,
inspectors=inspectors, frequencies=_FREQUENCIES,
frequency_labels=InspectionSchedule.FREQUENCY_LABELS,
title='Edit Inspection Schedule')
@bp.route('/<int:schedule_id>/delete', methods=['POST'])
@login_required
@schedule_manager_required
def delete(schedule_id):
schedule = db.session.get(InspectionSchedule, schedule_id)
if schedule is None:
abort(404)
if not _schedule_in_scope(schedule):
abort(403)
name = schedule.name
sid = schedule.id
db.session.delete(schedule)
db.session.commit()
log_action(ACTION_DELETE, 'InspectionSchedule', sid, name)
flash(f'Inspection schedule "{name}" deleted.', 'success')
return redirect(url_for('inspection_schedules.index'))
@bp.route('/<int:schedule_id>/run-now', methods=['POST'])
@login_required
@project_manager_required
def run_now(schedule_id):
"""Manually materialise one inspection from a schedule — for testing / ad-hoc."""
schedule = db.session.get(InspectionSchedule, schedule_id)
if schedule is None:
abort(404)
now = now_eastern()
inspection = _materialise(schedule, now)
schedule.last_run_at = now
# phase46/47: honours the day rules, the end-date boundary and 'once'
# (which deactivates), instead of the old flat interval.
schedule.advance_due_date(now)
db.session.commit()
log_action(ACTION_UPDATE, 'InspectionSchedule', schedule.id, schedule.name,
f'manual run_now by {current_user.username}; inspection_id={inspection.id}; '
f'next_due={schedule.due_date}; active={schedule.active}')
flash(f'Inspection created from "{schedule.name}". It is now in the inspector\'s queue.',
'success')
return redirect(url_for('inspection_schedules.index'))
# ── Start (plan mode: create the planned inspection and open the execute flow) ─
@bp.route('/<int:schedule_id>/start')
@login_required
def start(schedule_id):
"""Start the inspection this schedule plans for (phase43).
Deliberately NOT @project_manager_required: the whole point is that the
assigned inspector starts their own scheduled work. Customers are barred;
an inspector may only start their own schedule; managers may start any.
"""
schedule = db.session.get(InspectionSchedule, schedule_id)
if schedule is None:
abort(404)
if current_user.role == 'customer':
abort(403)
if current_user.is_inspector and schedule.inspector_id != current_user.id:
abort(403)
if not schedule.active:
flash('This schedule is no longer active.', 'warning')
return redirect(url_for('inspection_schedules.index'))
template = schedule.template
if template is None or not template.get_form_schema():
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,
area_id = schedule.area_id,
inspector_id = current_user.id,
inspection_date = now_eastern(),
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'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')
return redirect(url_for('inspections.execute', inspection_id=inspection.id))
# ── Acknowledge (inspector confirms receipt of the request) ───────────────────
@bp.route('/<int:schedule_id>/acknowledge', methods=['POST'])
@login_required
def acknowledge(schedule_id):
"""The assigned inspector confirms they received the scheduled request.
Assignee-only, exactly like Start: a manager cannot confirm on someone's
behalf, because the whole point of the record is that THIS person saw it.
Idempotent — confirming twice is a no-op. On the first confirmation the
schedule's creator is notified.
"""
schedule = db.session.get(InspectionSchedule, schedule_id)
if schedule is None:
abort(404)
if not schedule.inspector_id or schedule.inspector_id != current_user.id:
abort(403)
if _do_acknowledge(schedule, current_user.username):
flash('You have confirmed receipt of this scheduled inspection.', 'success')
else:
flash('You have already confirmed this scheduled inspection.', 'info')
return redirect(url_for('inspection_schedules.index'))
def _do_acknowledge(schedule, actor_username):
"""Stamp acknowledged_at, log, and notify the creator. Commits.
Idempotent: returns True if this call newly confirmed, False if it was
already confirmed. Shared by the logged-in POST route and the login-free
email-token GET route, so the two can never drift apart. The caller must
have already verified the actor is the assigned inspector.
"""
if schedule.acknowledged_at is not None:
return False
schedule.acknowledged_at = now_eastern()
db.session.commit()
log_action(ACTION_UPDATE, 'InspectionSchedule', schedule.id, schedule.name,
'inspector confirmed receipt')
logger.info('INSPECTION SCHEDULE ACKNOWLEDGED | schedule=%s | by=%s',
schedule.id, actor_username)
_notify_creator_acknowledged(schedule)
db.session.commit()
return True
# ── Confirm receipt from the assignment email (login-free, token-signed) ──────
@bp.route('/confirm/<token>')
def confirm_email(token):
"""One-click "Confirm receipt" landing from the assignment email (phase50).
Deliberately login-free and NOT @login_required: authorisation comes from the
signed token, which binds the schedule id to the inspector id it was issued
for. Inspectors read this on a phone that is usually not logged in, and a
login wall is exactly what stops them confirming.
Every failure mode renders the same standalone page with a different status
rather than a bare 4xx, because the audience is a non-technical user who
clicked a link in an email. GET is safe to repeat: the acknowledgement is
idempotent, so a re-click or an email client's link prefetch is harmless.
"""
try:
data = _ack_serializer().loads(token, max_age=_ACK_MAX_AGE)
except SignatureExpired:
return render_template('inspection_schedules/confirm_result.html',
status='expired'), 400
except BadSignature:
return render_template('inspection_schedules/confirm_result.html',
status='invalid'), 400
sid = data.get('sid')
schedule = db.session.get(InspectionSchedule, sid) if sid else None
if schedule is None:
return render_template('inspection_schedules/confirm_result.html',
status='missing'), 404
# The token's inspector must STILL be the assigned inspector. This is what
# makes a stateless token safe: reassignment invalidates the old link at
# redemption without needing to track issued tokens anywhere.
if not schedule.inspector_id or schedule.inspector_id != data.get('iid'):
return render_template('inspection_schedules/confirm_result.html',
status='reassigned', schedule=schedule), 409
if not schedule.active:
return render_template('inspection_schedules/confirm_result.html',
status='inactive', schedule=schedule)
newly = _do_acknowledge(
schedule,
schedule.inspector.username if schedule.inspector else 'inspector')
return render_template('inspection_schedules/confirm_result.html',
status='confirmed' if newly else 'already',
schedule=schedule)
# ── Cron endpoint ─────────────────────────────────────────────────────────────
@bp.route('/run', methods=['POST'])
@csrf.exempt
def run():
"""Token-protected endpoint called by cron to materialise all due schedules.
POST body: token=<DIGEST_SECRET>
"""
token = request.form.get('token') or request.args.get('token')
expected = current_app.config.get('DIGEST_SECRET')
if not expected or token != expected:
logger.warning('INSPECTION SCHEDULES RUN REJECTED | bad/missing token')
return jsonify({'ok': False, 'error': 'unauthorized'}), 403
now = now_eastern()
today = now.date()
schedules = InspectionSchedule.query.filter_by(active=True).all()
# ── Expiry sweep (phase47), BEFORE any materialisation or reminder work ──
# advance_due_date() closes out a schedule that reaches its boundary by
# producing an occurrence; this covers the one that reaches it without ever
# doing so — otherwise an auto schedule would keep materialising and a plan
# schedule would keep re-alerting as overdue, indefinitely.
expired = 0
live = []
for s in schedules:
if s.expire_if_past_end_date(today):
expired += 1
logger.info('INSPECTION SCHEDULE EXPIRED | schedule_id=%s | end_date=%s',
s.id, s.end_date)
else:
live.append(s)
if expired:
db.session.commit()
schedules = live
# Only 'auto' schedules materialise. 'plan' schedules wait for the inspector
# to click Start; they get reminders instead (below).
auto = [s for s in schedules if s.mode != 'plan']
due = [s for s in auto if s.next_run_at is None or s.next_run_at <= now]
created = 0
for schedule in due:
try:
inspection = _materialise(schedule, now)
schedule.last_run_at = now
schedule.advance_due_date(now)
created += 1
logger.info('INSPECTION SCHEDULE MATERIALISED | schedule_id=%s | inspection_id=%s',
schedule.id, inspection.id)
except Exception as exc:
# Advance the due date anyway so one broken schedule can't wedge the
# whole cron run on every subsequent tick.
schedule.advance_due_date(now)
logger.error('INSPECTION SCHEDULE FAILED | schedule_id=%s | error=%s',
schedule.id, exc)
db.session.commit()
sent = _dispatch_reminders([s for s in schedules if s.mode == 'plan'], now)
logger.info('INSPECTION SCHEDULES CRON | expired=%s | due=%s | created=%s | reminders=%s',
expired, len(due), created, sent)
return jsonify({'ok': True, 'expired': expired, 'due': len(due),
'created': created, 'reminders': sent})
def _dispatch_reminders(plans, now):
"""Advance / due / overdue reminders for plan-mode schedules (phase43).
Each fires at most once per occurrence via the *_notified flags, which reset
when the schedule rolls forward in fulfill(). Commits.
"""
today = now.date()
sent = {'advance': 0, 'due': 0, 'overdue': 0}
managers = User.query.filter(
User.role.in_(['admin', 'director']), User.active.is_(True)
).all()
for s in plans:
if s.next_run_at is None:
continue
due_date = s.next_run_at.date()
inspector = s.inspector
link = url_for('inspection_schedules.index')
fac_name = s.facility.name if s.facility else '—'
tpl_name = s.template.name if s.template else '—'
# Advance reminder — the day before it's due
if (not s.advance_notified and inspector and inspector.active
and due_date == today + timedelta(days=1)):
notify(
inspector,
title = f'Inspection due tomorrow — {fac_name}',
body = (f'Reminder: a "{tpl_name}" inspection at {fac_name} '
f'is scheduled for tomorrow ({due_date:%b %d, %Y}).'),
link = link,
event_type = EVENT_INSPECTION_SCHEDULED,
send_email = True,
# phase50 — still unconfirmed? Offer the button again here, so an
# inspector who missed the assignment email can confirm from
# whichever reminder reaches them. Returns None once confirmed.
extra_action = _confirm_action(s),
)
s.advance_notified = True
sent['advance'] += 1
# Due reminder — on/after the due date
if (not s.due_notified and inspector and inspector.active
and due_date <= today):
notify(
inspector,
title = f'Inspection due today — {fac_name}',
body = (f'A "{tpl_name}" inspection at {fac_name} is due '
f'({due_date:%b %d, %Y}). Please complete it.'),
link = link,
event_type = EVENT_INSPECTION_SCHEDULED,
send_email = True,
extra_action = _confirm_action(s), # phase50 — see above
)
s.due_notified = True
sent['due'] += 1
# Overdue alert — past due and still not fulfilled -> managers
if not s.overdue_notified and due_date < today:
for m in managers:
notify(
m,
title = f'Overdue scheduled inspection — {fac_name}',
body = (f'The "{tpl_name}" inspection at {fac_name} assigned to '
f'{inspector.display_name if inspector else "—"} was due '
f'{due_date:%b %d, %Y} and has not been completed.'),
link = link,
event_type = EVENT_INSPECTION_SCHEDULED,
send_email = True,
)
s.overdue_notified = True
sent['overdue'] += 1
db.session.commit()
return sent