""" 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('//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('//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')) # ── Start (plan mode: create the planned inspection and open the execute flow) ─ @bp.route('//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= """ 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