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
+7
View File
@@ -57,6 +57,13 @@ class Inspection(db.Model):
template_id = db.Column(db.Integer, db.ForeignKey('inspection_templates.id'), nullable=False)
facility_id = db.Column(db.Integer, db.ForeignKey('facilities.id'), nullable=False)
area_id = db.Column(db.Integer, db.ForeignKey('areas.id'))
# phase43: set when this inspection was started from / materialised by a
# schedule. ON DELETE SET NULL — deleting a schedule never deletes history.
inspection_schedule_id = db.Column(
db.Integer,
db.ForeignKey('inspection_schedules.id', ondelete='SET NULL'),
nullable=True, index=True
)
inspector_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
inspection_date = db.Column(db.DateTime, nullable=False, default=now_eastern)
overall_score = db.Column(db.Numeric(5, 2))
+67 -1
View File
@@ -13,6 +13,20 @@ their queue and fills it out through the normal execute flow.
This is purely additive: no existing inspection behaviour changes. A schedule is
just an automated `inspections.start()`.
phase43 adds the single-tenant "plan" semantics alongside that:
mode='auto' (default, phase34 behaviour)
Cron materialises the Inspection at next_run_at and notifies the inspector.
mode='plan' (ST behaviour)
Nothing is materialised. The schedule is a commitment with a due date; the
assigned inspector clicks "Start", which creates the Inspection linked back
via Inspection.inspection_schedule_id. Reminders fire in advance / on the
due date / once overdue. Completing the inspection calls fulfill(), which
deactivates a one-time schedule or rolls a recurring one forward.
`next_run_at` is the due datetime for both modes.
"""
from app import db
@@ -49,6 +63,11 @@ class InspectionSchedule(db.Model):
)
active = db.Column(db.Boolean, nullable=False, default=True)
# phase43: 'auto' = cron materialises the inspection (phase34 behaviour,
# the default for every pre-existing row); 'plan' = the inspector starts it.
mode = db.Column(db.Enum('auto', 'plan'), nullable=False, default='auto')
notes = db.Column(db.Text, nullable=True)
created_by = db.Column(
db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'),
nullable=True
@@ -57,6 +76,13 @@ class InspectionSchedule(db.Model):
last_run_at = db.Column(db.DateTime, nullable=True) # last successful materialisation
next_run_at = db.Column(db.DateTime, nullable=True) # when the next inspection is due
# phase43 — plan mode bookkeeping
last_completed_at = db.Column(db.DateTime, nullable=True)
# Per-occurrence reminder de-dup flags; reset when a recurring schedule rolls forward.
advance_notified = db.Column(db.Boolean, nullable=False, default=False)
due_notified = db.Column(db.Boolean, nullable=False, default=False)
overdue_notified = db.Column(db.Boolean, nullable=False, default=False)
# Relationships — explicit foreign_keys because two columns point at users.id.
template = db.relationship('InspectionTemplate', foreign_keys=[template_id])
facility = db.relationship('Facility', foreign_keys=[facility_id])
@@ -64,5 +90,45 @@ class InspectionSchedule(db.Model):
inspector = db.relationship('User', foreign_keys=[inspector_id])
creator = db.relationship('User', foreign_keys=[created_by])
FREQUENCY_LABELS = {
'daily': 'Daily',
'weekly': 'Weekly',
'monthly': 'Monthly',
'quarterly': 'Quarterly',
}
@property
def frequency_label(self):
return self.FREQUENCY_LABELS.get(self.frequency, self.frequency)
@property
def due_date(self):
"""The due date (date part of next_run_at), or None."""
return self.next_run_at.date() if self.next_run_at else None
def is_overdue(self, today=None):
"""True when an active schedule's due date has passed."""
if not self.active or self.next_run_at is None:
return False
today = today or now_eastern().date()
return self.next_run_at.date() < today
def fulfill(self, next_run_fn=None):
"""Mark this occurrence complete. Caller commits.
Recurring schedules roll their due date forward past today and reset the
reminder flags; MT has no 'once' frequency, so a schedule stays active.
`next_run_fn(frequency, from_dt)` computes the next due datetime — the
route passes `_compute_next_run` so the cadence math lives in one place.
"""
now = now_eastern()
self.last_completed_at = now
if next_run_fn is not None:
self.next_run_at = next_run_fn(self.frequency, now)
self.advance_notified = False
self.due_notified = False
self.overdue_notified = False
def __repr__(self):
return f'<InspectionSchedule {self.id} {self.name!r} {self.frequency}>'
return (f'<InspectionSchedule {self.id} {self.name!r} '
f'{self.frequency} mode={self.mode}>')
+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}',
+48
View File
@@ -11,6 +11,54 @@
</div>
</div>
{# ── Scheduled Inspections (phase43) — plan-mode, staff only ─────────────── #}
{% if current_user.role != 'customer' and (sched_upcoming or sched_overdue_count) %}
<div class="card shadow-sm mb-4 border-0" style="border-left:4px solid #6366f1 !important;">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="fw-bold"><i class="bi bi-calendar-check text-primary"></i> Scheduled Inspections</span>
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-sm btn-outline-primary">View all</a>
</div>
{% if sched_overdue_count %}
<div class="alert alert-danger py-2 mb-2">
<i class="bi bi-alarm-fill"></i>
<strong>{{ sched_overdue_count }}</strong> scheduled inspection{{ 's' if sched_overdue_count != 1 }}
{{ 'are' if sched_overdue_count != 1 else 'is' }} <strong>overdue</strong>.
</div>
{% endif %}
{% if sched_upcoming %}
<div class="table-responsive">
<table class="table table-sm table-hover mb-0 align-middle">
<thead class="table-light">
<tr><th>Schedule</th><th>Facility</th><th>Template</th><th>Inspector</th><th>Due</th><th></th></tr>
</thead>
<tbody>
{% for s in sched_upcoming %}
<tr>
<td class="fw-semibold">{{ s.name }}</td>
<td>{{ s.facility.name if s.facility else '—' }}</td>
<td class="small">{{ s.template.name if s.template else '—' }}</td>
<td class="small">{{ s.inspector.display_name if s.inspector else '—' }}</td>
<td class="small">{{ s.next_run_at.strftime('%b %d') if s.next_run_at else '—' }}</td>
<td class="text-end">
{% if current_user.role in ['admin','director','project_manager','auditor']
or (current_user.role == 'inspector' and s.inspector_id == current_user.id) %}
<a href="{{ url_for('inspection_schedules.start', schedule_id=s.id) }}"
class="btn btn-sm btn-success py-0"><i class="bi bi-play-fill"></i> Start</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted small mb-0">No inspections due in the next 7 days.</p>
{% endif %}
</div>
</div>
{% endif %}
{# ── Inspections section ─────────────────────────────────────────────────── #}
<div class="d-flex align-items-center gap-2 mb-3">
<i class="bi bi-clipboard-data-fill text-primary"></i>
+28 -1
View File
@@ -39,6 +39,29 @@
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Mode</label>
<select name="mode" class="form-select">
<option value="auto" {{ 'selected' if not schedule or schedule.mode != 'plan' }}>
Auto — create the inspection automatically each period</option>
<option value="plan" {{ 'selected' if schedule and schedule.mode == 'plan' }}>
Plan — inspector presses Start (with due/overdue reminders)</option>
</select>
<div class="form-text">
Auto drops an in-progress inspection into the inspector's queue on
schedule. Plan assigns a due date and reminds them the day before, on
the day, and alerts managers once it's overdue.
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Notes for the inspector <span class="text-muted small">(optional)</span></label>
<textarea name="notes" class="form-control" rows="2"
placeholder="Anything the inspector should know before starting">{{ schedule.notes if schedule and schedule.notes else '' }}</textarea>
<div class="form-text">Copied onto the inspection when it starts.</div>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Facility</label>
@@ -80,8 +103,12 @@
<label class="form-check-label" for="activeSwitch">Active</label>
</div>
<p class="text-muted small">
Saving recomputes the next run from now. Next run:
Saving recomputes the next {{ 'due date' if schedule.mode == 'plan' else 'run' }}
from now, and resets this occurrence's reminders. Currently:
{{ schedule.next_run_at.strftime('%Y-%m-%d %H:%M') if schedule.next_run_at else '—' }}
{% if schedule.last_completed_at %}
· last completed {{ schedule.last_completed_at.strftime('%Y-%m-%d %H:%M') }}
{% endif %}
</p>
{% endif %}
</div>
+33 -5
View File
@@ -3,15 +3,24 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-calendar2-week"></i> Inspection Schedules</h2>
{% if current_user.role != 'inspector' %}
<a href="{{ url_for('inspection_schedules.create') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> New Schedule
</a>
{% endif %}
</div>
<p class="text-muted small mb-4">
Recurring schedules automatically create an in-progress inspection for the
assigned inspector each period. The inspector is notified and opens it from
their Inspections list to complete it.
{% if current_user.role == 'inspector' %}
Inspections scheduled for you. <strong>Auto</strong> schedules appear in your
Inspections list on their own each period; <strong>Plan</strong> schedules wait
for you to press Start.
{% else %}
<strong>Auto</strong> schedules automatically create an in-progress inspection
for the assigned inspector each period. <strong>Plan</strong> schedules assign a
due date and let the inspector press Start when they begin — with reminders the
day before, on the day, and an alert to managers once overdue.
{% endif %}
</p>
{% if schedules %}
@@ -22,8 +31,8 @@
<thead class="table-light">
<tr>
<th>Name</th><th>Template</th><th>Facility / Area</th>
<th>Inspector</th><th>Frequency</th><th>Next Run</th>
<th>Last Run</th><th>Status</th><th width="150"></th>
<th>Inspector</th><th>Frequency</th><th>Mode</th><th>Next Due</th>
<th>Last Run</th><th>Status</th><th width="190"></th>
</tr>
</thead>
<tbody>
@@ -37,8 +46,18 @@
</td>
<td>{{ s.inspector.display_name if s.inspector else '—' }}</td>
<td><span class="badge bg-secondary">{{ s.frequency|title }}</span></td>
<td>
{% if s.mode == 'plan' %}
<span class="badge bg-info text-dark" title="Inspector presses Start">Plan</span>
{% else %}
<span class="badge bg-light text-dark border" title="Cron creates the inspection">Auto</span>
{% endif %}
</td>
<td class="small {{ 'text-danger fw-semibold' if s.active and s.next_run_at and s.next_run_at <= now else 'text-muted' }}">
{{ s.next_run_at.strftime('%Y-%m-%d %H:%M') if s.next_run_at else '—' }}
{% if s.is_overdue(today) %}
<span class="badge bg-danger ms-1">Overdue</span>
{% endif %}
</td>
<td class="small text-muted">
{{ s.last_run_at.strftime('%Y-%m-%d %H:%M') if s.last_run_at else 'Never' }}
@@ -48,6 +67,14 @@
{% else %}<span class="badge bg-secondary">Paused</span>{% endif %}
</td>
<td class="text-end">
{% if s.active and s.mode == 'plan'
and (current_user.role != 'inspector' or s.inspector_id == current_user.id) %}
<a href="{{ url_for('inspection_schedules.start', schedule_id=s.id) }}"
class="btn btn-sm btn-primary" title="Start this inspection now">
<i class="bi bi-play-fill"></i> Start
</a>
{% endif %}
{% if current_user.role != 'inspector' %}
<a href="{{ url_for('inspection_schedules.edit', schedule_id=s.id) }}"
class="btn btn-sm btn-outline-secondary" title="Edit">
<i class="bi bi-pencil"></i>
@@ -70,6 +97,7 @@
<i class="bi bi-trash3"></i>
</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
@@ -0,0 +1,120 @@
"""phase43 — plan semantics for inspection_schedules
Extends MT's `inspection_schedules` (phase34) with the single-tenant "plan"
model (ST phase36_scheduled_inspections), rather than adding a second, competing
scheduler table. Adds:
notes TEXT — free-text brief for the inspector
last_completed_at DATETIME — when the last occurrence was fulfilled
advance_notified BOOL — per-occurrence reminder de-dup flags, reset
due_notified BOOL when a recurring schedule rolls forward
overdue_notified BOOL
mode ENUM — 'auto' = cron materialises the Inspection
'plan' = inspector clicks Start (ST behaviour)
inspections.inspection_schedule_id — FK back to the originating schedule
`next_run_at` (phase34) is reused as the due datetime — ST's `next_due_date` by
another name. No duplicate column, no renames (rule 7).
Existing rows keep working exactly as before: `mode` defaults to 'auto', which
is the only behaviour that has ever existed in MT. Nothing flips to 'plan'
unless someone chooses it in the form.
INFORMATION_SCHEMA-guarded throughout — safe to re-run on every tenant DB.
"""
revision = 'phase43_schedule_plan_fields'
down_revision = 'phase42_area_qr_token'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def _column_exists(conn, table, column):
return conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() "
"AND TABLE_NAME = :t AND COLUMN_NAME = :c"
), {"t": table, "c": column}).scalar() > 0
def _fk_exists(conn, table, name):
return conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t "
"AND CONSTRAINT_NAME = :n AND CONSTRAINT_TYPE = 'FOREIGN KEY'"
), {"t": table, "n": name}).scalar() > 0
def _index_exists(conn, table, index):
return conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS "
"WHERE TABLE_SCHEMA = DATABASE() "
"AND TABLE_NAME = :t AND INDEX_NAME = :i"
), {"t": table, "i": index}).scalar() > 0
_NEW_COLUMNS = (
('notes', 'TEXT NULL'),
('last_completed_at', 'DATETIME NULL'),
('advance_notified', 'TINYINT(1) NOT NULL DEFAULT 0'),
('due_notified', 'TINYINT(1) NOT NULL DEFAULT 0'),
('overdue_notified', 'TINYINT(1) NOT NULL DEFAULT 0'),
('mode', "ENUM('auto','plan') NOT NULL DEFAULT 'auto'"),
)
def upgrade():
bind = op.get_bind()
for name, ddl in _NEW_COLUMNS:
if not _column_exists(bind, 'inspection_schedules', name):
op.execute(sa.text(
f"ALTER TABLE inspection_schedules ADD COLUMN {name} {ddl}"
))
# Link a materialised/started Inspection back to its schedule.
if not _column_exists(bind, 'inspections', 'inspection_schedule_id'):
op.execute(sa.text(
"ALTER TABLE inspections ADD COLUMN inspection_schedule_id INT NULL"
))
if not _index_exists(bind, 'inspections', 'ix_inspections_inspection_schedule_id'):
op.execute(sa.text(
"CREATE INDEX ix_inspections_inspection_schedule_id "
"ON inspections (inspection_schedule_id)"
))
if not _fk_exists(bind, 'inspections', 'fk_inspections_inspection_schedule'):
# ON DELETE SET NULL: deleting a schedule must never delete completed
# inspection history.
op.execute(sa.text(
"ALTER TABLE inspections "
"ADD CONSTRAINT fk_inspections_inspection_schedule "
"FOREIGN KEY (inspection_schedule_id) "
"REFERENCES inspection_schedules (id) ON DELETE SET NULL"
))
def downgrade():
bind = op.get_bind()
if _fk_exists(bind, 'inspections', 'fk_inspections_inspection_schedule'):
op.execute(sa.text(
"ALTER TABLE inspections DROP FOREIGN KEY fk_inspections_inspection_schedule"
))
if _index_exists(bind, 'inspections', 'ix_inspections_inspection_schedule_id'):
op.execute(sa.text(
"DROP INDEX ix_inspections_inspection_schedule_id ON inspections"
))
if _column_exists(bind, 'inspections', 'inspection_schedule_id'):
op.execute(sa.text(
"ALTER TABLE inspections DROP COLUMN inspection_schedule_id"
))
for name, _ddl in reversed(_NEW_COLUMNS):
if _column_exists(bind, 'inspection_schedules', name):
op.execute(sa.text(
f"ALTER TABLE inspection_schedules DROP COLUMN {name}"
))
+7 -1
View File
@@ -67,7 +67,13 @@ def test_cron_materialises_due_schedule_and_notifies(client):
resp = client.post('/inspection-schedules/run', data={'token': 'test-digest'})
assert resp.status_code == 200
assert resp.get_json() == {'ok': True, 'due': 1, 'created': 1}
# phase43 widened this response with a 'reminders' key (plan-mode schedules).
# Materialisation of 'auto' schedules is unchanged.
payload = resp.get_json()
assert payload['ok'] is True
assert payload['due'] == 1
assert payload['created'] == 1
assert payload['reminders'] == {'advance': 0, 'due': 0, 'overdue': 0}
# A real in_progress inspection now exists for the assigned inspector.
insp = Inspection.query.filter_by(inspector_id=insp_user_id).first()