933 lines
42 KiB
Python
933 lines
42 KiB
Python
"""
|
||
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 (1–31).')
|
||
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)
|
||
|
||
# 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.role == 'inspector':
|
||
base = base.filter(InspectionSchedule.inspector_id == current_user.id)
|
||
|
||
# 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 _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
|