Jul 17 - Fill the gaps between Single-tenant mode and Multi-tenant mode - MT4
This commit is contained in:
@@ -2,7 +2,19 @@
|
||||
app/routes/inspection_schedules.py
|
||||
-----------------------------------
|
||||
CRUD management for recurring InspectionSchedule configs + a cron-triggered
|
||||
materialisation endpoint (phase34).
|
||||
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
|
||||
@@ -38,6 +50,7 @@ logger = logging.getLogger(__name__)
|
||||
bp = Blueprint('inspection_schedules', __name__, url_prefix='/inspection-schedules')
|
||||
|
||||
_FREQUENCIES = ('daily', 'weekly', 'monthly', 'quarterly')
|
||||
_MODES = ('auto', 'plan')
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
@@ -69,6 +82,29 @@ def _active_inspectors():
|
||||
).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.
|
||||
|
||||
@@ -82,7 +118,8 @@ def _materialise(schedule: InspectionSchedule, when: datetime) -> Inspection:
|
||||
inspector_id = schedule.inspector_id,
|
||||
inspection_date = when,
|
||||
status = 'in_progress',
|
||||
notes = None,
|
||||
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
|
||||
@@ -106,13 +143,29 @@ def _materialise(schedule: InspectionSchedule, when: datetime) -> Inspection:
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
@project_manager_required
|
||||
def index():
|
||||
schedules = InspectionSchedule.query.order_by(
|
||||
InspectionSchedule.active.desc(), InspectionSchedule.name
|
||||
"""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_eastern())
|
||||
schedules=schedules, now=now, today=now.date())
|
||||
|
||||
|
||||
def _form_choices():
|
||||
@@ -135,6 +188,8 @@ def create():
|
||||
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:
|
||||
@@ -147,6 +202,8 @@ def create():
|
||||
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:
|
||||
@@ -163,6 +220,8 @@ def create():
|
||||
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(),
|
||||
@@ -171,7 +230,12 @@ def create():
|
||||
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}')
|
||||
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'))
|
||||
|
||||
@@ -191,6 +255,7 @@ def edit(schedule_id):
|
||||
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)
|
||||
@@ -206,13 +271,27 @@ def edit(schedule_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}; active={schedule.active}')
|
||||
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'))
|
||||
|
||||
@@ -258,6 +337,55 @@ def run_now(schedule_id):
|
||||
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'])
|
||||
@@ -276,7 +404,10 @@ def run():
|
||||
|
||||
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]
|
||||
# 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:
|
||||
@@ -295,5 +426,80 @@ def run():
|
||||
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})
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user