|
|
|
@@ -3,7 +3,9 @@ app/routes/scheduled_inspections.py
|
|
|
|
|
------------------------------------
|
|
|
|
|
Planned / recurring inspection assignments (phase36).
|
|
|
|
|
|
|
|
|
|
Management (list/new/edit/delete) : admin, director, project_manager
|
|
|
|
|
Management (list/new/edit/delete) : admin, director, project_manager, auditor,
|
|
|
|
|
plus CUSTOMER DIRECTORS, scoped to the
|
|
|
|
|
facilities on their own contracts
|
|
|
|
|
Start (execute the planned inspection): the assigned inspector ONLY (the person
|
|
|
|
|
who must do it) — not managers. A manager
|
|
|
|
|
who needs to run it assigns it to themselves.
|
|
|
|
@@ -30,7 +32,8 @@ from app.models.inspection import Inspection, InspectionTemplate
|
|
|
|
|
from app.models.project import Project
|
|
|
|
|
from app.models.user import User
|
|
|
|
|
from app.utils.forms import ScheduledInspectionForm
|
|
|
|
|
from app.utils.decorators import project_manager_required
|
|
|
|
|
from functools import wraps
|
|
|
|
|
from app.utils.scope import get_customer_scope
|
|
|
|
|
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
|
|
|
|
|
from app.utils.time_utils import now_eastern
|
|
|
|
|
from app.utils.notifications import notify
|
|
|
|
@@ -75,23 +78,146 @@ def _confirm_action(sched):
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#: Our own staff who plan inspections. Customer Directors are added on top by
|
|
|
|
|
#: schedule_manager_required — they plan work for their OWN facilities only.
|
|
|
|
|
_STAFF_SCHEDULERS = ('admin', 'director', 'project_manager', 'auditor')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_customer_director(user):
|
|
|
|
|
"""True only for the portal customer role.
|
|
|
|
|
|
|
|
|
|
Equality on purpose (rule 89): a Customer Inspector PERFORMS scheduled
|
|
|
|
|
inspections, they do not plan them, and they are scoped by
|
|
|
|
|
InspectorAssignment rather than CustomerAssignment. Widening this to
|
|
|
|
|
User.CUSTOMER_ROLES would hand them a planning screen scoped by the wrong
|
|
|
|
|
table — i.e. no facilities at all.
|
|
|
|
|
"""
|
|
|
|
|
return getattr(user, 'role', None) == 'customer'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def schedule_manager_required(f):
|
|
|
|
|
"""Who may create / edit / delete a scheduled inspection.
|
|
|
|
|
|
|
|
|
|
Our staff (_STAFF_SCHEDULERS) plus **Customer Directors**, who schedule
|
|
|
|
|
inspections for the facilities they are assigned. Every choice list and
|
|
|
|
|
every POST is narrowed to their own contracts — see _populate_choices(),
|
|
|
|
|
_active_contracts() and _schedule_in_scope().
|
|
|
|
|
"""
|
|
|
|
|
@wraps(f)
|
|
|
|
|
def wrapper(*args, **kwargs):
|
|
|
|
|
if not current_user.is_authenticated:
|
|
|
|
|
abort(403)
|
|
|
|
|
if current_user.role in _STAFF_SCHEDULERS or _is_customer_director(current_user):
|
|
|
|
|
return f(*args, **kwargs)
|
|
|
|
|
flash('You do not have permission to manage scheduled inspections.', 'danger')
|
|
|
|
|
return redirect(url_for('dashboard.index'))
|
|
|
|
|
return wrapper
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _customer_facility_ids():
|
|
|
|
|
"""Facility ids the current Customer Director may schedule against."""
|
|
|
|
|
return set(get_customer_scope(current_user) or [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _customer_project_ids():
|
|
|
|
|
"""Contract ids behind those facilities.
|
|
|
|
|
|
|
|
|
|
Derived from the facilities rather than straight off CustomerAssignment, so
|
|
|
|
|
a facility-level assignment resolves to its owning contract and the
|
|
|
|
|
contract selector still lines up with the facilities on offer.
|
|
|
|
|
"""
|
|
|
|
|
fids = _customer_facility_ids()
|
|
|
|
|
if not fids:
|
|
|
|
|
return set()
|
|
|
|
|
return {
|
|
|
|
|
f.project_id
|
|
|
|
|
for f in Facility.query.filter(Facility.id.in_(fids)).all()
|
|
|
|
|
if f.project_id
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _schedule_in_scope(sched):
|
|
|
|
|
"""May the current user act on this schedule?
|
|
|
|
|
|
|
|
|
|
Staff: any. Customer Director: only schedules at a facility they are
|
|
|
|
|
assigned — checked on edit and delete so a hand-typed id cannot reach
|
|
|
|
|
another customer's schedule.
|
|
|
|
|
"""
|
|
|
|
|
if not _is_customer_director(current_user):
|
|
|
|
|
return True
|
|
|
|
|
return sched.facility_id in _customer_facility_ids()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _populate_choices(form):
|
|
|
|
|
# facility_id choices are ALL active facilities so POST validation passes
|
|
|
|
|
# regardless of which contract the UI-only contract selector had chosen
|
|
|
|
|
# (CLAUDE.md rule 61). The contract selector narrows the list client-side.
|
|
|
|
|
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
|
|
|
|
templates = (InspectionTemplate.query
|
|
|
|
|
.filter_by(active=True).order_by(InspectionTemplate.name).all())
|
|
|
|
|
inspectors = (User.query.filter(User.role.in_(User.INSPECTOR_ROLES),
|
|
|
|
|
User.active == True)
|
|
|
|
|
.order_by(User.username).all())
|
|
|
|
|
#
|
|
|
|
|
# For a Customer Director every list is narrowed to their own contracts.
|
|
|
|
|
# These choices ARE the POST validation (SelectField rejects anything not
|
|
|
|
|
# offered), so this is the security boundary, not just a tidier dropdown:
|
|
|
|
|
# a hand-crafted facility_id or inspector_id from another customer fails
|
|
|
|
|
# validation here rather than being stored.
|
|
|
|
|
customer_scoped = _is_customer_director(current_user)
|
|
|
|
|
|
|
|
|
|
fac_q = Facility.query.filter_by(active=True)
|
|
|
|
|
if customer_scoped:
|
|
|
|
|
fids = _customer_facility_ids()
|
|
|
|
|
fac_q = fac_q.filter(Facility.id.in_(fids)) if fids else fac_q.filter(False)
|
|
|
|
|
facilities = fac_q.order_by(Facility.name).all()
|
|
|
|
|
|
|
|
|
|
if customer_scoped:
|
|
|
|
|
# Shared forms plus those attached to their contracts (phase52) — the
|
|
|
|
|
# same union the mobile API builds, so another customer's bespoke form
|
|
|
|
|
# NAMES never appear in the picker (rule 96).
|
|
|
|
|
seen, templates = set(), []
|
|
|
|
|
for pid in list(_customer_project_ids()) + [None]:
|
|
|
|
|
for t in InspectionTemplate.available_query(pid).all():
|
|
|
|
|
if t.id not in seen:
|
|
|
|
|
seen.add(t.id)
|
|
|
|
|
templates.append(t)
|
|
|
|
|
templates.sort(key=lambda t: (t.name or '').lower())
|
|
|
|
|
else:
|
|
|
|
|
templates = (InspectionTemplate.query
|
|
|
|
|
.filter_by(active=True).order_by(InspectionTemplate.name).all())
|
|
|
|
|
|
|
|
|
|
insp_q = User.query.filter(User.role.in_(User.INSPECTOR_ROLES),
|
|
|
|
|
User.active == True)
|
|
|
|
|
if customer_scoped:
|
|
|
|
|
# Only inspectors working on their contracts — theirs and ours. Without
|
|
|
|
|
# this a customer would see (and could assign) every inspector in the
|
|
|
|
|
# system, including another client's Customer Inspector, exactly as the
|
|
|
|
|
# flag-issue dropdown used to (rule 93).
|
|
|
|
|
from app.models.inspector_assignment import InspectorAssignment
|
|
|
|
|
pids = _customer_project_ids()
|
|
|
|
|
insp_q = (insp_q.join(InspectorAssignment,
|
|
|
|
|
InspectorAssignment.user_id == User.id)
|
|
|
|
|
.filter(InspectorAssignment.project_id.in_(pids))
|
|
|
|
|
if pids else insp_q.filter(False))
|
|
|
|
|
inspectors = insp_q.order_by(User.username).all()
|
|
|
|
|
# The join can repeat a user across assignment rows.
|
|
|
|
|
_seen_ids, _uniq = set(), []
|
|
|
|
|
for u in inspectors:
|
|
|
|
|
if u.id not in _seen_ids:
|
|
|
|
|
_seen_ids.add(u.id)
|
|
|
|
|
_uniq.append(u)
|
|
|
|
|
inspectors = _uniq
|
|
|
|
|
form.facility_id.choices = [(f.id, f.name) for f in facilities]
|
|
|
|
|
form.template_id.choices = [(t.id, t.name) for t in templates]
|
|
|
|
|
form.inspector_id.choices = [(u.id, u.display_name) for u in inspectors]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _active_contracts():
|
|
|
|
|
return Project.query.filter_by(active=True).order_by(Project.name).all()
|
|
|
|
|
"""Contracts offered in the UI-only contract selector.
|
|
|
|
|
|
|
|
|
|
Narrowed to the Customer Director's own contracts so the selector cannot
|
|
|
|
|
even name another customer's contract.
|
|
|
|
|
"""
|
|
|
|
|
q = Project.query.filter_by(active=True)
|
|
|
|
|
if _is_customer_director(current_user):
|
|
|
|
|
pids = _customer_project_ids()
|
|
|
|
|
q = q.filter(Project.id.in_(pids)) if pids else q.filter(False)
|
|
|
|
|
return q.order_by(Project.name).all()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _notify_assignee(sched, reassigned=False):
|
|
|
|
@@ -192,6 +318,29 @@ def _reject_if_past_end_date(sched, form):
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _reject_facility_out_of_scope(form):
|
|
|
|
|
"""True (and a form error set) if a Customer Director picked a facility
|
|
|
|
|
outside their own contracts.
|
|
|
|
|
|
|
|
|
|
Belt-and-braces: _populate_choices() already narrows facility_id, and
|
|
|
|
|
SelectField rejects anything not offered, so this should be unreachable.
|
|
|
|
|
It is here because that guard lives in how a list was BUILT — one future
|
|
|
|
|
change to the choice-building and a crafted POST would otherwise schedule
|
|
|
|
|
work at another customer's building.
|
|
|
|
|
"""
|
|
|
|
|
if not _is_customer_director(current_user):
|
|
|
|
|
return False
|
|
|
|
|
if form.facility_id.data in _customer_facility_ids():
|
|
|
|
|
return False
|
|
|
|
|
current_app.logger.warning(
|
|
|
|
|
'SCHED INSP | out-of-scope facility blocked | user=%s | facility_id=%s',
|
|
|
|
|
current_user.username, form.facility_id.data,
|
|
|
|
|
)
|
|
|
|
|
form.facility_id.errors.append(
|
|
|
|
|
'That facility is not one of yours. Choose a facility from your contracts.')
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _reject_template_not_on_contract(form):
|
|
|
|
|
"""True (and a form error set) if the chosen form isn't usable at the
|
|
|
|
|
chosen facility (phase52).
|
|
|
|
@@ -246,8 +395,9 @@ def _selected_project_id(form):
|
|
|
|
|
@bp.route('/')
|
|
|
|
|
@login_required
|
|
|
|
|
def index():
|
|
|
|
|
if current_user.role == 'customer':
|
|
|
|
|
abort(403)
|
|
|
|
|
# Customer Directors now plan inspections for their own facilities, so they
|
|
|
|
|
# reach this list too — narrowed below. Customer Inspectors already saw it
|
|
|
|
|
# (they are inspectors) and keep their own-assignments-only view.
|
|
|
|
|
|
|
|
|
|
# Two tabs (phase47): Pending = schedules still producing occurrences
|
|
|
|
|
# (active); Completed = closed schedules (fulfilled one-times, ended
|
|
|
|
@@ -264,6 +414,13 @@ def index():
|
|
|
|
|
# Inspectors see only their own assignments; managers see everything.
|
|
|
|
|
if current_user.is_inspector:
|
|
|
|
|
base = base.filter(ScheduledInspection.inspector_id == current_user.id)
|
|
|
|
|
elif _is_customer_director(current_user):
|
|
|
|
|
# Only schedules at facilities they are assigned. An empty scope must
|
|
|
|
|
# match nothing rather than everything — filter(False), not a skipped
|
|
|
|
|
# filter (rule 57's failure mode).
|
|
|
|
|
fids = _customer_facility_ids()
|
|
|
|
|
base = (base.filter(ScheduledInspection.facility_id.in_(fids))
|
|
|
|
|
if fids else base.filter(False))
|
|
|
|
|
|
|
|
|
|
pending_count = base.filter(ScheduledInspection.active.is_(True)).count()
|
|
|
|
|
completed_count = base.filter(ScheduledInspection.active.is_(False)).count()
|
|
|
|
@@ -288,7 +445,7 @@ def index():
|
|
|
|
|
|
|
|
|
|
@bp.route('/new', methods=['GET', 'POST'])
|
|
|
|
|
@login_required
|
|
|
|
|
@project_manager_required
|
|
|
|
|
@schedule_manager_required
|
|
|
|
|
def create():
|
|
|
|
|
form = ScheduledInspectionForm()
|
|
|
|
|
_populate_choices(form)
|
|
|
|
@@ -299,7 +456,9 @@ def create():
|
|
|
|
|
if not form.next_due_date.data:
|
|
|
|
|
form.next_due_date.data = now_eastern().date()
|
|
|
|
|
|
|
|
|
|
if form.validate_on_submit() and not _reject_template_not_on_contract(form):
|
|
|
|
|
if (form.validate_on_submit()
|
|
|
|
|
and not _reject_facility_out_of_scope(form)
|
|
|
|
|
and not _reject_template_not_on_contract(form)):
|
|
|
|
|
sched = ScheduledInspection(
|
|
|
|
|
facility_id = form.facility_id.data,
|
|
|
|
|
template_id = form.template_id.data,
|
|
|
|
@@ -342,11 +501,13 @@ def create():
|
|
|
|
|
|
|
|
|
|
@bp.route('/<int:schedule_id>/edit', methods=['GET', 'POST'])
|
|
|
|
|
@login_required
|
|
|
|
|
@project_manager_required
|
|
|
|
|
@schedule_manager_required
|
|
|
|
|
def edit(schedule_id):
|
|
|
|
|
sched = db.session.get(ScheduledInspection, schedule_id)
|
|
|
|
|
if sched is None:
|
|
|
|
|
abort(404)
|
|
|
|
|
if not _schedule_in_scope(sched):
|
|
|
|
|
abort(403)
|
|
|
|
|
form = ScheduledInspectionForm(obj=sched)
|
|
|
|
|
_populate_choices(form)
|
|
|
|
|
form.next_due_date.label.text = 'Next Due Date'
|
|
|
|
@@ -356,7 +517,9 @@ def edit(schedule_id):
|
|
|
|
|
form.weekdays.data = sched.weekday_list
|
|
|
|
|
form.month_mode.data = sched.month_mode or MONTH_MODE_DAY
|
|
|
|
|
|
|
|
|
|
if form.validate_on_submit() and not _reject_template_not_on_contract(form):
|
|
|
|
|
if (form.validate_on_submit()
|
|
|
|
|
and not _reject_facility_out_of_scope(form)
|
|
|
|
|
and not _reject_template_not_on_contract(form)):
|
|
|
|
|
old_inspector_id = sched.inspector_id
|
|
|
|
|
sched.facility_id = form.facility_id.data
|
|
|
|
|
sched.template_id = form.template_id.data
|
|
|
|
@@ -407,11 +570,13 @@ def edit(schedule_id):
|
|
|
|
|
|
|
|
|
|
@bp.route('/<int:schedule_id>/delete', methods=['POST'])
|
|
|
|
|
@login_required
|
|
|
|
|
@project_manager_required
|
|
|
|
|
@schedule_manager_required
|
|
|
|
|
def delete(schedule_id):
|
|
|
|
|
sched = db.session.get(ScheduledInspection, schedule_id)
|
|
|
|
|
if sched is None:
|
|
|
|
|
abort(404)
|
|
|
|
|
if not _schedule_in_scope(sched):
|
|
|
|
|
abort(403)
|
|
|
|
|
label = f'{sched.template.name if sched.template else "?"} @ {sched.facility.name if sched.facility else "?"}'
|
|
|
|
|
sid = sched.id
|
|
|
|
|
db.session.delete(sched)
|
|
|
|
|