Files
JQC_multi_tenant/app/routes/inspection_schedules.py
T

904 lines
41 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.user import User
from app.utils.decorators import project_manager_required
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
def _active_inspectors():
"""Users who can be assigned inspections (inspector-capable roles)."""
return User.query.filter(
User.active.is_(True),
User.role.in_(['inspector', 'project_manager', 'director', 'admin']),
).order_by(User.full_name, User.username).all()
# ── 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
# ── 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.
"""
if current_user.role == 'customer':
abort(403)
q = InspectionSchedule.query
if current_user.role == 'inspector':
q = q.filter(InspectionSchedule.inspector_id == current_user.id)
schedules = q.order_by(
InspectionSchedule.active.desc(),
InspectionSchedule.next_run_at.asc(),
InspectionSchedule.name,
).all()
now = now_eastern()
return render_template('inspection_schedules/index.html',
schedules=schedules, now=now, today=now.date())
def _form_choices():
templates = InspectionTemplate.query.filter_by(active=True).order_by(InspectionTemplate.name).all()
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
inspectors = _active_inspectors()
return templates, facilities, inspectors
@bp.route('/new', methods=['GET', 'POST'])
@login_required
@project_manager_required
def create():
templates, facilities, inspectors = _form_choices()
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))
if errors:
for e in errors:
flash(e, 'warning')
return render_template('inspection_schedules/form.html',
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',
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',
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
@project_manager_required
def edit(schedule_id):
schedule = db.session.get(InspectionSchedule, schedule_id)
if schedule is None:
abort(404)
templates, facilities, inspectors = _form_choices()
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)
if errors:
db.session.rollback()
for e in errors:
flash(e, 'warning')
return render_template('inspection_schedules/form.html',
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',
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', 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
@project_manager_required
def delete(schedule_id):
schedule = db.session.get(InspectionSchedule, schedule_id)
if schedule is None:
abort(404)
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.role == '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