Jul 8 - Update scheduled inspection
This commit is contained in:
@@ -337,8 +337,27 @@ def index():
|
||||
.all()
|
||||
)
|
||||
|
||||
# ── Scheduled inspections (phase36): upcoming / overdue ──────────────
|
||||
sched_upcoming = []
|
||||
sched_overdue_count = 0
|
||||
if not is_customer:
|
||||
from app.models.scheduled_inspection import ScheduledInspection
|
||||
_today = now.date()
|
||||
_sq = ScheduledInspection.query.filter_by(active=True)
|
||||
if is_inspector:
|
||||
_sq = _sq.filter(ScheduledInspection.inspector_id == current_user.id)
|
||||
_all_sched = _sq.order_by(ScheduledInspection.next_due_date.asc()).all()
|
||||
sched_overdue_count = sum(1 for s in _all_sched if s.next_due_date < _today)
|
||||
# Upcoming = due today through the next 7 days (overdue shown separately)
|
||||
sched_upcoming = [
|
||||
s for s in _all_sched
|
||||
if _today <= s.next_due_date <= _today + timedelta(days=7)
|
||||
][:8]
|
||||
|
||||
return render_template(
|
||||
'dashboard.html',
|
||||
sched_upcoming = sched_upcoming,
|
||||
sched_overdue_count = sched_overdue_count,
|
||||
today_inspections = today_inspections,
|
||||
completed_today = completed_today,
|
||||
open_issues = open_issues,
|
||||
|
||||
@@ -579,6 +579,21 @@ def execute(inspection_id):
|
||||
inspection_id = inspection.id,
|
||||
facility_id = inspection.facility_id,
|
||||
)
|
||||
|
||||
# Fulfill the originating scheduled inspection, if any: one-time
|
||||
# schedules deactivate; recurring ones roll their due date forward
|
||||
# and reset reminder flags. Staged in the same atomic commit below.
|
||||
if inspection.scheduled_inspection_id:
|
||||
from app.models.scheduled_inspection import ScheduledInspection
|
||||
sched = db.session.get(ScheduledInspection, inspection.scheduled_inspection_id)
|
||||
if sched is not None:
|
||||
sched.fulfill()
|
||||
current_app.logger.info(
|
||||
'SCHED INSP | fulfilled | schedule=%s | inspection=%s | next_due=%s',
|
||||
sched.id, inspection.id,
|
||||
sched.next_due_date if sched.active else 'deactivated',
|
||||
)
|
||||
|
||||
db.session.commit() # Single atomic commit: inspection fields + notification rows
|
||||
log_action(ACTION_UPDATE, 'Inspection', inspection.id,
|
||||
f'{inspection.template.name} @ {inspection.facility.name}',
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
app/routes/scheduled_inspections.py
|
||||
------------------------------------
|
||||
Planned / recurring inspection assignments (phase36).
|
||||
|
||||
Management (list/new/edit/delete) : admin, director, project_manager
|
||||
Start (execute the planned inspection): the assigned inspector, or admin/director/pm
|
||||
Cron reminders : POST /run?token=DIGEST_SECRET (no login)
|
||||
|
||||
Fulfillment (marking a schedule done and rolling recurring ones forward) happens
|
||||
in the inspection execute route when the linked inspection is completed —
|
||||
see app/routes/inspections.py.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from flask import (Blueprint, render_template, redirect, url_for, flash,
|
||||
request, abort, current_app)
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from app import db
|
||||
from app.models.scheduled_inspection import ScheduledInspection
|
||||
from app.models.facility import Facility
|
||||
from app.models.inspection import Inspection, InspectionTemplate
|
||||
from app.models.user import User
|
||||
from app.utils.forms import ScheduledInspectionForm
|
||||
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_SCHEDULED_INSPECTION
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('scheduled_inspections', __name__, url_prefix='/scheduled-inspections')
|
||||
|
||||
|
||||
def _populate_choices(form):
|
||||
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
||||
templates = (InspectionTemplate.query
|
||||
.filter_by(active=True).order_by(InspectionTemplate.name).all())
|
||||
inspectors = (User.query.filter_by(role='inspector', active=True)
|
||||
.order_by(User.username).all())
|
||||
form.facility_id.choices = [(f.id, f.name) for f in facilities]
|
||||
form.template_id.choices = [(t.id, t.name) for t in templates]
|
||||
form.inspector_id.choices = [(u.id, u.display_name) for u in inspectors]
|
||||
|
||||
|
||||
# ── List ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
if current_user.role == 'customer':
|
||||
abort(403)
|
||||
|
||||
today = now_eastern().date()
|
||||
q = ScheduledInspection.query
|
||||
|
||||
# Inspectors see only their own assignments; managers see everything.
|
||||
if current_user.role == 'inspector':
|
||||
q = q.filter(ScheduledInspection.inspector_id == current_user.id)
|
||||
|
||||
schedules = q.order_by(
|
||||
ScheduledInspection.active.desc(),
|
||||
ScheduledInspection.next_due_date.asc(),
|
||||
).all()
|
||||
|
||||
return render_template('scheduled_inspections/list.html',
|
||||
schedules=schedules, today=today)
|
||||
|
||||
|
||||
# ── Create ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@project_manager_required
|
||||
def create():
|
||||
form = ScheduledInspectionForm()
|
||||
_populate_choices(form)
|
||||
if not form.next_due_date.data:
|
||||
form.next_due_date.data = now_eastern().date()
|
||||
|
||||
if form.validate_on_submit():
|
||||
sched = ScheduledInspection(
|
||||
facility_id = form.facility_id.data,
|
||||
template_id = form.template_id.data,
|
||||
inspector_id = form.inspector_id.data,
|
||||
frequency = form.frequency.data,
|
||||
next_due_date = form.next_due_date.data,
|
||||
notes = (form.notes.data or '').strip() or None,
|
||||
active = form.active.data,
|
||||
created_by = current_user.id,
|
||||
)
|
||||
db.session.add(sched)
|
||||
db.session.commit()
|
||||
log_action(ACTION_CREATE, 'ScheduledInspection', sched.id,
|
||||
f'{sched.template.name} @ {sched.facility.name}',
|
||||
f'freq={sched.frequency}; due={sched.next_due_date}; inspector={sched.inspector_id}')
|
||||
logger.info('SCHED INSP | create | by=%s | id=%s', current_user.username, sched.id)
|
||||
flash('Scheduled inspection created.', 'success')
|
||||
return redirect(url_for('scheduled_inspections.index'))
|
||||
|
||||
return render_template('scheduled_inspections/form.html',
|
||||
form=form, title='New Scheduled Inspection')
|
||||
|
||||
|
||||
# ── Edit ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:schedule_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@project_manager_required
|
||||
def edit(schedule_id):
|
||||
sched = db.session.get(ScheduledInspection, schedule_id)
|
||||
if sched is None:
|
||||
abort(404)
|
||||
form = ScheduledInspectionForm(obj=sched)
|
||||
_populate_choices(form)
|
||||
|
||||
if form.validate_on_submit():
|
||||
sched.facility_id = form.facility_id.data
|
||||
sched.template_id = form.template_id.data
|
||||
sched.inspector_id = form.inspector_id.data
|
||||
sched.frequency = form.frequency.data
|
||||
sched.next_due_date = form.next_due_date.data
|
||||
sched.notes = (form.notes.data or '').strip() or None
|
||||
sched.active = form.active.data
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'ScheduledInspection', sched.id,
|
||||
f'{sched.template.name} @ {sched.facility.name}',
|
||||
f'freq={sched.frequency}; due={sched.next_due_date}; active={sched.active}')
|
||||
flash('Scheduled inspection updated.', 'success')
|
||||
return redirect(url_for('scheduled_inspections.index'))
|
||||
|
||||
return render_template('scheduled_inspections/form.html',
|
||||
form=form, title='Edit Scheduled Inspection', schedule=sched)
|
||||
|
||||
|
||||
# ── Delete ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:schedule_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@project_manager_required
|
||||
def delete(schedule_id):
|
||||
sched = db.session.get(ScheduledInspection, schedule_id)
|
||||
if sched is None:
|
||||
abort(404)
|
||||
label = f'{sched.template.name if sched.template else "?"} @ {sched.facility.name if sched.facility else "?"}'
|
||||
sid = sched.id
|
||||
db.session.delete(sched)
|
||||
db.session.commit()
|
||||
log_action(ACTION_DELETE, 'ScheduledInspection', sid, label)
|
||||
flash('Scheduled inspection deleted.', 'success')
|
||||
return redirect(url_for('scheduled_inspections.index'))
|
||||
|
||||
|
||||
# ── Start (create the planned inspection and open the execute flow) ───────────
|
||||
|
||||
@bp.route('/<int:schedule_id>/start')
|
||||
@login_required
|
||||
def start(schedule_id):
|
||||
sched = db.session.get(ScheduledInspection, schedule_id)
|
||||
if sched is None:
|
||||
abort(404)
|
||||
if current_user.role == 'customer':
|
||||
abort(403)
|
||||
|
||||
# Only the assigned inspector, or a manager, may start it.
|
||||
if current_user.role == 'inspector' and sched.inspector_id != current_user.id:
|
||||
abort(403)
|
||||
|
||||
if not sched.active:
|
||||
flash('This scheduled inspection is no longer active.', 'warning')
|
||||
return redirect(url_for('scheduled_inspections.index'))
|
||||
|
||||
template = sched.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('scheduled_inspections.index'))
|
||||
|
||||
inspection = Inspection(
|
||||
template_id = sched.template_id,
|
||||
facility_id = sched.facility_id,
|
||||
area_id = None,
|
||||
inspector_id = current_user.id,
|
||||
inspection_date = now_eastern(),
|
||||
status = 'in_progress',
|
||||
scheduled_inspection_id = sched.id,
|
||||
)
|
||||
db.session.add(inspection)
|
||||
db.session.commit()
|
||||
log_action(ACTION_CREATE, 'Inspection', inspection.id,
|
||||
f'{inspection.template.name} @ {inspection.facility.name}',
|
||||
f'from scheduled_inspection_id={sched.id}')
|
||||
logger.info('SCHED INSP | start | schedule=%s | inspection=%s | by=%s',
|
||||
sched.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: reminders (advance / due / overdue) ─────────────────────────────────
|
||||
|
||||
@bp.route('/run', methods=['POST'])
|
||||
def run_reminders():
|
||||
token = request.form.get('token') or request.args.get('token')
|
||||
if not token or token != current_app.config.get('DIGEST_SECRET'):
|
||||
abort(403)
|
||||
|
||||
today = now_eastern().date()
|
||||
sent = {'advance': 0, 'due': 0, 'overdue': 0}
|
||||
|
||||
schedules = ScheduledInspection.query.filter_by(active=True).all()
|
||||
|
||||
# Cache admin/director recipients for overdue alerts
|
||||
managers = User.query.filter(
|
||||
User.role.in_(['admin', 'director']), User.active == True # noqa: E712
|
||||
).all()
|
||||
|
||||
for s in schedules:
|
||||
inspector = s.inspector
|
||||
link = url_for('scheduled_inspections.index')
|
||||
fac_name = s.facility.name if s.facility else '—'
|
||||
tpl_name = s.template.name if s.template else '—'
|
||||
|
||||
# Advance reminder — 1 day before due
|
||||
if (not s.advance_notified and inspector and inspector.active
|
||||
and s.next_due_date == today + timedelta(days=1)):
|
||||
notify(
|
||||
recipient = inspector,
|
||||
title = f'Inspection due tomorrow — {fac_name}',
|
||||
body = (f'Reminder: a "{tpl_name}" inspection at {fac_name} '
|
||||
f'is scheduled for tomorrow ({s.next_due_date:%b %d, %Y}).'),
|
||||
link = link,
|
||||
event_type = EVENT_SCHEDULED_INSPECTION,
|
||||
send_email = True,
|
||||
)
|
||||
s.advance_notified = True
|
||||
sent['advance'] += 1
|
||||
|
||||
# Due reminder — on/after due date
|
||||
if (not s.due_notified and inspector and inspector.active
|
||||
and s.next_due_date <= today):
|
||||
notify(
|
||||
recipient = inspector,
|
||||
title = f'Inspection due today — {fac_name}',
|
||||
body = (f'A "{tpl_name}" inspection at {fac_name} is due '
|
||||
f'({s.next_due_date:%b %d, %Y}). Please complete it.'),
|
||||
link = link,
|
||||
event_type = EVENT_SCHEDULED_INSPECTION,
|
||||
send_email = True,
|
||||
)
|
||||
s.due_notified = True
|
||||
sent['due'] += 1
|
||||
|
||||
# Overdue alert — due date has passed, still not fulfilled
|
||||
if not s.overdue_notified and s.next_due_date < today:
|
||||
for m in managers:
|
||||
notify(
|
||||
recipient = 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'{s.next_due_date:%b %d, %Y} and has not been completed.'),
|
||||
link = link,
|
||||
event_type = EVENT_SCHEDULED_INSPECTION,
|
||||
send_email = True,
|
||||
)
|
||||
s.overdue_notified = True
|
||||
sent['overdue'] += 1
|
||||
|
||||
db.session.commit()
|
||||
logger.info('SCHED INSP | reminders | advance=%s due=%s overdue=%s',
|
||||
sent['advance'], sent['due'], sent['overdue'])
|
||||
return {'ok': True, 'sent': sent}, 200
|
||||
Reference in New Issue
Block a user