Files
JQC_multi_tenant/app/routes/inspection_schedules.py
T

506 lines
22 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 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
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 = ('daily', 'weekly', 'monthly', 'quarterly')
_MODES = ('auto', 'plan')
# ── 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.
Monthly/quarterly advance by calendar months (targeting the same day-of-month
is avoided — we simply add 30/90 days, which is predictable and never raises
the datetime.replace(month=13) ValueError).
"""
now = from_dt or now_eastern()
if frequency == 'daily':
base = now + timedelta(days=1)
elif frequency == 'weekly':
base = now + timedelta(weeks=1)
elif frequency == 'monthly':
base = now + timedelta(days=30)
else: # quarterly
base = now + timedelta(days=90)
return base.replace(hour=6, minute=0, second=0, microsecond=0)
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()
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.frequency_label.lower()}), due {due}.'),
link = url_for('inspection_schedules.index'),
event_type = EVENT_INSPECTION_SCHEDULED,
send_email = True,
)
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
)
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.')
if errors:
for e in errors:
flash(e, 'warning')
return render_template('inspection_schedules/form.html',
templates=templates, facilities=facilities,
inspectors=inspectors, frequencies=_FREQUENCIES,
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(),
next_run_at = _compute_next_run(frequency),
)
db.session.add(schedule)
db.session.commit()
log_action(ACTION_CREATE, 'InspectionSchedule', schedule.id, schedule.name,
f'frequency={frequency}; mode={mode}; template_id={template_id}; '
f'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,
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 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):
schedule.inspector_id = inspector_id
schedule.area_id = request.form.get('area_id', type=int) or None
if frequency in _FREQUENCIES:
schedule.frequency = frequency
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'))
# Recompute the next run from now against the (possibly changed) cadence.
schedule.next_run_at = _compute_next_run(schedule.frequency)
# New occurrence -> the previous occurrence's reminders no longer apply.
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.frequency}; mode={schedule.mode}; '
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,
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
schedule.next_run_at = _compute_next_run(schedule.frequency, 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}')
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'))
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,
)
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}')
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))
# ── 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()
schedules = InspectionSchedule.query.filter_by(active=True).all()
# 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.next_run_at = _compute_next_run(schedule.frequency, now)
created += 1
logger.info('INSPECTION SCHEDULE MATERIALISED | schedule_id=%s | inspection_id=%s',
schedule.id, inspection.id)
except Exception as exc:
# Advance next_run_at anyway so one broken schedule can't wedge the
# whole cron run on every subsequent tick.
schedule.next_run_at = _compute_next_run(schedule.frequency, 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 | due=%s | created=%s | reminders=%s',
len(due), created, sent)
return jsonify({'ok': True, '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,
)
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,
)
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