Jul 17 - Fill the gaps between Single-tenant mode and Multi-tenant mode - MT4
This commit is contained in:
@@ -57,6 +57,13 @@ class Inspection(db.Model):
|
|||||||
template_id = db.Column(db.Integer, db.ForeignKey('inspection_templates.id'), nullable=False)
|
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)
|
facility_id = db.Column(db.Integer, db.ForeignKey('facilities.id'), nullable=False)
|
||||||
area_id = db.Column(db.Integer, db.ForeignKey('areas.id'))
|
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)
|
inspector_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
inspection_date = db.Column(db.DateTime, nullable=False, default=now_eastern)
|
inspection_date = db.Column(db.DateTime, nullable=False, default=now_eastern)
|
||||||
overall_score = db.Column(db.Numeric(5, 2))
|
overall_score = db.Column(db.Numeric(5, 2))
|
||||||
|
|||||||
@@ -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
|
This is purely additive: no existing inspection behaviour changes. A schedule is
|
||||||
just an automated `inspections.start()`.
|
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
|
from app import db
|
||||||
@@ -49,6 +63,11 @@ class InspectionSchedule(db.Model):
|
|||||||
)
|
)
|
||||||
active = db.Column(db.Boolean, nullable=False, default=True)
|
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(
|
created_by = db.Column(
|
||||||
db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'),
|
db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'),
|
||||||
nullable=True
|
nullable=True
|
||||||
@@ -57,6 +76,13 @@ class InspectionSchedule(db.Model):
|
|||||||
last_run_at = db.Column(db.DateTime, nullable=True) # last successful materialisation
|
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
|
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.
|
# Relationships — explicit foreign_keys because two columns point at users.id.
|
||||||
template = db.relationship('InspectionTemplate', foreign_keys=[template_id])
|
template = db.relationship('InspectionTemplate', foreign_keys=[template_id])
|
||||||
facility = db.relationship('Facility', foreign_keys=[facility_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])
|
inspector = db.relationship('User', foreign_keys=[inspector_id])
|
||||||
creator = db.relationship('User', foreign_keys=[created_by])
|
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):
|
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}>')
|
||||||
|
|||||||
@@ -344,8 +344,32 @@ def index():
|
|||||||
.all()
|
.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(
|
return render_template(
|
||||||
'dashboard.html',
|
'dashboard.html',
|
||||||
|
sched_upcoming = sched_upcoming,
|
||||||
|
sched_overdue_count = sched_overdue_count,
|
||||||
today_inspections = today_inspections,
|
today_inspections = today_inspections,
|
||||||
completed_today = completed_today,
|
completed_today = completed_today,
|
||||||
open_issues = open_issues,
|
open_issues = open_issues,
|
||||||
|
|||||||
@@ -2,7 +2,19 @@
|
|||||||
app/routes/inspection_schedules.py
|
app/routes/inspection_schedules.py
|
||||||
-----------------------------------
|
-----------------------------------
|
||||||
CRUD management for recurring InspectionSchedule configs + a cron-triggered
|
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),
|
Management is admin / director / project_manager (@project_manager_required),
|
||||||
mirroring who may start inspections. The /run route is token-protected with the
|
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')
|
bp = Blueprint('inspection_schedules', __name__, url_prefix='/inspection-schedules')
|
||||||
|
|
||||||
_FREQUENCIES = ('daily', 'weekly', 'monthly', 'quarterly')
|
_FREQUENCIES = ('daily', 'weekly', 'monthly', 'quarterly')
|
||||||
|
_MODES = ('auto', 'plan')
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
@@ -69,6 +82,29 @@ def _active_inspectors():
|
|||||||
).order_by(User.full_name, User.username).all()
|
).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:
|
def _materialise(schedule: InspectionSchedule, when: datetime) -> Inspection:
|
||||||
"""Create an in_progress Inspection from a schedule and notify the inspector.
|
"""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,
|
inspector_id = schedule.inspector_id,
|
||||||
inspection_date = when,
|
inspection_date = when,
|
||||||
status = 'in_progress',
|
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.add(inspection)
|
||||||
db.session.flush() # assign inspection.id without committing
|
db.session.flush() # assign inspection.id without committing
|
||||||
@@ -106,13 +143,29 @@ def _materialise(schedule: InspectionSchedule, when: datetime) -> Inspection:
|
|||||||
|
|
||||||
@bp.route('/')
|
@bp.route('/')
|
||||||
@login_required
|
@login_required
|
||||||
@project_manager_required
|
|
||||||
def index():
|
def index():
|
||||||
schedules = InspectionSchedule.query.order_by(
|
"""Schedule list.
|
||||||
InspectionSchedule.active.desc(), InspectionSchedule.name
|
|
||||||
|
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()
|
).all()
|
||||||
|
now = now_eastern()
|
||||||
return render_template('inspection_schedules/index.html',
|
return render_template('inspection_schedules/index.html',
|
||||||
schedules=schedules, now=now_eastern())
|
schedules=schedules, now=now, today=now.date())
|
||||||
|
|
||||||
|
|
||||||
def _form_choices():
|
def _form_choices():
|
||||||
@@ -135,6 +188,8 @@ def create():
|
|||||||
area_id = request.form.get('area_id', type=int) or None
|
area_id = request.form.get('area_id', type=int) or None
|
||||||
inspector_id = request.form.get('inspector_id', type=int)
|
inspector_id = request.form.get('inspector_id', type=int)
|
||||||
frequency = request.form.get('frequency', 'weekly')
|
frequency = request.form.get('frequency', 'weekly')
|
||||||
|
mode = request.form.get('mode', 'auto')
|
||||||
|
notes = request.form.get('notes', '').strip() or None
|
||||||
|
|
||||||
errors = []
|
errors = []
|
||||||
if not name:
|
if not name:
|
||||||
@@ -147,6 +202,8 @@ def create():
|
|||||||
errors.append('Please choose a valid inspector.')
|
errors.append('Please choose a valid inspector.')
|
||||||
if frequency not in _FREQUENCIES:
|
if frequency not in _FREQUENCIES:
|
||||||
errors.append('Invalid frequency.')
|
errors.append('Invalid frequency.')
|
||||||
|
if mode not in _MODES:
|
||||||
|
errors.append('Invalid mode.')
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
for e in errors:
|
for e in errors:
|
||||||
@@ -163,6 +220,8 @@ def create():
|
|||||||
area_id = area_id,
|
area_id = area_id,
|
||||||
inspector_id = inspector_id,
|
inspector_id = inspector_id,
|
||||||
frequency = frequency,
|
frequency = frequency,
|
||||||
|
mode = mode,
|
||||||
|
notes = notes,
|
||||||
active = True,
|
active = True,
|
||||||
created_by = current_user.id,
|
created_by = current_user.id,
|
||||||
created_at = now_eastern(),
|
created_at = now_eastern(),
|
||||||
@@ -171,7 +230,12 @@ def create():
|
|||||||
db.session.add(schedule)
|
db.session.add(schedule)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
log_action(ACTION_CREATE, 'InspectionSchedule', schedule.id, schedule.name,
|
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')
|
flash(f'Inspection schedule "{schedule.name}" created.', 'success')
|
||||||
return redirect(url_for('inspection_schedules.index'))
|
return redirect(url_for('inspection_schedules.index'))
|
||||||
|
|
||||||
@@ -191,6 +255,7 @@ def edit(schedule_id):
|
|||||||
templates, facilities, inspectors = _form_choices()
|
templates, facilities, inspectors = _form_choices()
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
|
old_inspector_id = schedule.inspector_id
|
||||||
schedule.name = request.form.get('name', '').strip() or schedule.name
|
schedule.name = request.form.get('name', '').strip() or schedule.name
|
||||||
template_id = request.form.get('template_id', type=int)
|
template_id = request.form.get('template_id', type=int)
|
||||||
facility_id = request.form.get('facility_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
|
schedule.area_id = request.form.get('area_id', type=int) or None
|
||||||
if frequency in _FREQUENCIES:
|
if frequency in _FREQUENCIES:
|
||||||
schedule.frequency = frequency
|
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'))
|
schedule.active = bool(request.form.get('active'))
|
||||||
# Recompute the next run from now against the (possibly changed) cadence.
|
# Recompute the next run from now against the (possibly changed) cadence.
|
||||||
schedule.next_run_at = _compute_next_run(schedule.frequency)
|
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()
|
db.session.commit()
|
||||||
log_action(ACTION_UPDATE, 'InspectionSchedule', schedule.id, schedule.name,
|
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')
|
flash(f'Inspection schedule "{schedule.name}" updated.', 'success')
|
||||||
return redirect(url_for('inspection_schedules.index'))
|
return redirect(url_for('inspection_schedules.index'))
|
||||||
|
|
||||||
@@ -258,6 +337,55 @@ def run_now(schedule_id):
|
|||||||
return redirect(url_for('inspection_schedules.index'))
|
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 ─────────────────────────────────────────────────────────────
|
# ── Cron endpoint ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@bp.route('/run', methods=['POST'])
|
@bp.route('/run', methods=['POST'])
|
||||||
@@ -276,7 +404,10 @@ def run():
|
|||||||
|
|
||||||
now = now_eastern()
|
now = now_eastern()
|
||||||
schedules = InspectionSchedule.query.filter_by(active=True).all()
|
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
|
created = 0
|
||||||
for schedule in due:
|
for schedule in due:
|
||||||
@@ -295,5 +426,80 @@ def run():
|
|||||||
schedule.id, exc)
|
schedule.id, exc)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
logger.info('INSPECTION SCHEDULES CRON | due=%s | created=%s', len(due), created)
|
sent = _dispatch_reminders([s for s in schedules if s.mode == 'plan'], now)
|
||||||
return jsonify({'ok': True, 'due': len(due), 'created': created})
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -580,6 +580,20 @@ def execute(inspection_id):
|
|||||||
inspection_id = inspection.id,
|
inspection_id = inspection.id,
|
||||||
facility_id = inspection.facility_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
|
db.session.commit() # Single atomic commit: inspection fields + notification rows
|
||||||
log_action(ACTION_UPDATE, 'Inspection', inspection.id,
|
log_action(ACTION_UPDATE, 'Inspection', inspection.id,
|
||||||
f'{inspection.template.name} @ {inspection.facility.name}',
|
f'{inspection.template.name} @ {inspection.facility.name}',
|
||||||
|
|||||||
@@ -11,6 +11,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 ─────────────────────────────────────────────────── #}
|
{# ── Inspections section ─────────────────────────────────────────────────── #}
|
||||||
<div class="d-flex align-items-center gap-2 mb-3">
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
<i class="bi bi-clipboard-data-fill text-primary"></i>
|
<i class="bi bi-clipboard-data-fill text-primary"></i>
|
||||||
|
|||||||
@@ -39,6 +39,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="row">
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label">Facility</label>
|
<label class="form-label">Facility</label>
|
||||||
@@ -80,8 +103,12 @@
|
|||||||
<label class="form-check-label" for="activeSwitch">Active</label>
|
<label class="form-check-label" for="activeSwitch">Active</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted small">
|
<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 '—' }}
|
{{ 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>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,15 +3,24 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h2><i class="bi bi-calendar2-week"></i> Inspection Schedules</h2>
|
<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">
|
<a href="{{ url_for('inspection_schedules.create') }}" class="btn btn-primary">
|
||||||
<i class="bi bi-plus-circle"></i> New Schedule
|
<i class="bi bi-plus-circle"></i> New Schedule
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-muted small mb-4">
|
<p class="text-muted small mb-4">
|
||||||
Recurring schedules automatically create an in-progress inspection for the
|
{% if current_user.role == 'inspector' %}
|
||||||
assigned inspector each period. The inspector is notified and opens it from
|
Inspections scheduled for you. <strong>Auto</strong> schedules appear in your
|
||||||
their Inspections list to complete it.
|
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>
|
</p>
|
||||||
|
|
||||||
{% if schedules %}
|
{% if schedules %}
|
||||||
@@ -22,8 +31,8 @@
|
|||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th><th>Template</th><th>Facility / Area</th>
|
<th>Name</th><th>Template</th><th>Facility / Area</th>
|
||||||
<th>Inspector</th><th>Frequency</th><th>Next Run</th>
|
<th>Inspector</th><th>Frequency</th><th>Mode</th><th>Next Due</th>
|
||||||
<th>Last Run</th><th>Status</th><th width="150"></th>
|
<th>Last Run</th><th>Status</th><th width="190"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -37,8 +46,18 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{{ s.inspector.display_name if s.inspector else '—' }}</td>
|
<td>{{ s.inspector.display_name if s.inspector else '—' }}</td>
|
||||||
<td><span class="badge bg-secondary">{{ s.frequency|title }}</span></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' }}">
|
<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 '—' }}
|
{{ 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>
|
||||||
<td class="small text-muted">
|
<td class="small text-muted">
|
||||||
{{ s.last_run_at.strftime('%Y-%m-%d %H:%M') if s.last_run_at else 'Never' }}
|
{{ 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 %}
|
{% else %}<span class="badge bg-secondary">Paused</span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">
|
<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) }}"
|
<a href="{{ url_for('inspection_schedules.edit', schedule_id=s.id) }}"
|
||||||
class="btn btn-sm btn-outline-secondary" title="Edit">
|
class="btn btn-sm btn-outline-secondary" title="Edit">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
@@ -70,6 +97,7 @@
|
|||||||
<i class="bi bi-trash3"></i>
|
<i class="bi bi-trash3"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% 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}"
|
||||||
|
))
|
||||||
@@ -67,7 +67,13 @@ def test_cron_materialises_due_schedule_and_notifies(client):
|
|||||||
|
|
||||||
resp = client.post('/inspection-schedules/run', data={'token': 'test-digest'})
|
resp = client.post('/inspection-schedules/run', data={'token': 'test-digest'})
|
||||||
assert resp.status_code == 200
|
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.
|
# A real in_progress inspection now exists for the assigned inspector.
|
||||||
insp = Inspection.query.filter_by(inspector_id=insp_user_id).first()
|
insp = Inspection.query.filter_by(inspector_id=insp_user_id).first()
|
||||||
|
|||||||
Reference in New Issue
Block a user