Jul 17 - Fill the gaps between Single-tenant mode and Multi-tenant mode - MT4

This commit is contained in:
2026-07-17 11:36:05 -04:00
parent 253291d5a4
commit d44d761706
10 changed files with 565 additions and 19 deletions
+24
View File
@@ -344,8 +344,32 @@ def index():
.all()
)
# ── Scheduled inspections (phase43): upcoming / overdue ───────────────────
# Plan-mode only: 'auto' schedules materialise themselves into the
# inspections list, so surfacing them here would double-report the work.
sched_upcoming = []
sched_overdue_count = 0
if not is_customer:
from app.models.inspection_schedule import InspectionSchedule
_today = now_eastern().date()
_sq = InspectionSchedule.query.filter(
InspectionSchedule.active.is_(True),
InspectionSchedule.mode == 'plan',
)
if is_inspector:
_sq = _sq.filter(InspectionSchedule.inspector_id == current_user.id)
_all_sched = _sq.order_by(InspectionSchedule.next_run_at.asc()).all()
sched_overdue_count = sum(1 for s in _all_sched if s.is_overdue(_today))
# Upcoming = due today through the next 7 days (overdue shown separately)
sched_upcoming = [
s for s in _all_sched
if s.next_run_at and _today <= s.next_run_at.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,
+217 -11
View File
@@ -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
+14
View File
@@ -580,6 +580,20 @@ def execute(inspection_id):
inspection_id = inspection.id,
facility_id = inspection.facility_id,
)
# Fulfill the originating schedule, if any: roll a recurring plan's
# due date forward and reset its reminder flags. Staged in the same
# atomic commit below. (phase43)
if inspection.inspection_schedule_id:
from app.models.inspection_schedule import InspectionSchedule
from app.routes.inspection_schedules import _compute_next_run
sched = db.session.get(InspectionSchedule, inspection.inspection_schedule_id)
if sched is not None:
sched.fulfill(next_run_fn=_compute_next_run)
current_app.logger.info(
'INSPECTION SCHEDULE | fulfilled | schedule=%s | inspection=%s | next_due=%s',
sched.id, inspection.id, sched.next_run_at,
)
db.session.commit() # Single atomic commit: inspection fields + notification rows
log_action(ACTION_UPDATE, 'Inspection', inspection.id,
f'{inspection.template.name} @ {inspection.facility.name}',