300 lines
13 KiB
Python
300 lines
13 KiB
Python
"""
|
|
app/routes/inspection_schedules.py
|
|
-----------------------------------
|
|
CRUD management for recurring InspectionSchedule configs + a cron-triggered
|
|
materialisation endpoint (phase34).
|
|
|
|
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')
|
|
|
|
|
|
# ── 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 _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 = None,
|
|
)
|
|
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
|
|
@project_manager_required
|
|
def index():
|
|
schedules = InspectionSchedule.query.order_by(
|
|
InspectionSchedule.active.desc(), InspectionSchedule.name
|
|
).all()
|
|
return render_template('inspection_schedules/index.html',
|
|
schedules=schedules, now=now_eastern())
|
|
|
|
|
|
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')
|
|
|
|
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 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,
|
|
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}; template_id={template_id}; facility_id={facility_id}')
|
|
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':
|
|
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
|
|
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)
|
|
|
|
db.session.commit()
|
|
log_action(ACTION_UPDATE, 'InspectionSchedule', schedule.id, schedule.name,
|
|
f'frequency={schedule.frequency}; active={schedule.active}')
|
|
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'))
|
|
|
|
|
|
# ── 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()
|
|
due = [s for s in schedules 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()
|
|
|
|
logger.info('INSPECTION SCHEDULES CRON | due=%s | created=%s', len(due), created)
|
|
return jsonify({'ok': True, 'due': len(due), 'created': created})
|