""" 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('//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('//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('//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= """ 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})