Aug 19 - Update: now customer Director user can create scheduled task for their staff
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user