Aug 19 - Update code to catch up with ST
This commit is contained in:
@@ -43,7 +43,9 @@ from app.models.inspection_schedule import (InspectionSchedule,
|
||||
from app.models.inspection import Inspection, InspectionTemplate
|
||||
from app.models.facility import Facility, Area
|
||||
from app.models.user import User
|
||||
from functools import wraps
|
||||
from app.utils.decorators import project_manager_required
|
||||
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
|
||||
@@ -326,6 +328,142 @@ def _materialise(schedule: InspectionSchedule, when: datetime) -> Inspection:
|
||||
return inspection
|
||||
|
||||
|
||||
# ── Who may plan an inspection ───────────────────────────────────────────────
|
||||
|
||||
#: 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 _form_choices(),
|
||||
_scope_errors() 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 _scope_errors(template_id, facility_id, inspector_id):
|
||||
"""Validate a submitted schedule against the actor's scope and phase55.
|
||||
|
||||
This route builds its form by hand (no WTForms SelectField), so narrowing
|
||||
the choice lists is NOT the validation — a crafted POST would sail past it.
|
||||
Every id is therefore re-checked here:
|
||||
|
||||
* Customer Director — facility and inspector must belong to their own
|
||||
contracts, otherwise they could schedule work at, or assign it to,
|
||||
another customer.
|
||||
* Everyone — the chosen form must be available on the chosen facility's
|
||||
contract (phase55). Without this a manager could schedule one
|
||||
customer's bespoke form against another customer's facility, and the
|
||||
mismatch would only surface when the inspector opened it.
|
||||
"""
|
||||
errors = []
|
||||
facility = db.session.get(Facility, facility_id) if facility_id else None
|
||||
|
||||
if _is_customer_director(current_user):
|
||||
fids = _customer_facility_ids()
|
||||
if not facility_id or facility_id not in fids:
|
||||
logger.warning(
|
||||
'SCHED INSP | out-of-scope facility blocked | user=%s | facility_id=%s',
|
||||
current_user.username, facility_id)
|
||||
errors.append('That facility is not one of yours. '
|
||||
'Choose a facility from your contracts.')
|
||||
if inspector_id and inspector_id not in {u.id for u in _schedulable_inspectors()}:
|
||||
logger.warning(
|
||||
'SCHED INSP | out-of-scope inspector blocked | user=%s | user_id=%s',
|
||||
current_user.username, inspector_id)
|
||||
errors.append('That inspector does not work on your contracts.')
|
||||
|
||||
template = db.session.get(InspectionTemplate, template_id) if template_id else None
|
||||
if template is not None and facility is not None:
|
||||
if not template.available_for_project(facility.project_id):
|
||||
contract = facility.project.name if facility.project else "this facility's contract"
|
||||
errors.append(f'"{template.name}" is not available on {contract}. '
|
||||
f'Choose a form attached to that contract, or a shared form.')
|
||||
return errors
|
||||
|
||||
|
||||
def _schedulable_inspectors():
|
||||
"""Inspectors the current user may assign a schedule to.
|
||||
|
||||
A Customer Director sees only inspectors holding an InspectorAssignment on
|
||||
their own contracts — their own people and ours, never another client's
|
||||
Customer Inspector (rule 93). Staff see the whole active pool.
|
||||
"""
|
||||
q = User.query.filter(User.role.in_(User.INSPECTOR_ROLES), User.active == True)
|
||||
if _is_customer_director(current_user):
|
||||
from app.models.inspector_assignment import InspectorAssignment
|
||||
pids = _customer_project_ids()
|
||||
q = (q.join(InspectorAssignment, InspectorAssignment.user_id == User.id)
|
||||
.filter(InspectorAssignment.project_id.in_(pids))
|
||||
if pids else q.filter(False))
|
||||
seen, uniq = set(), []
|
||||
for u in q.order_by(User.username).all():
|
||||
# The join can repeat a user across assignment rows.
|
||||
if u.id not in seen:
|
||||
seen.add(u.id)
|
||||
uniq.append(u)
|
||||
return uniq
|
||||
|
||||
|
||||
# ── CRUD ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/')
|
||||
@@ -338,8 +476,9 @@ def index():
|
||||
customers are barred; managers see everything, exactly as before. All
|
||||
mutating routes below keep @project_manager_required.
|
||||
"""
|
||||
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 (phase51): Pending = schedules still producing occurrences
|
||||
# (active); Completed = closed ones — fulfilled one-times, recurring
|
||||
@@ -359,6 +498,13 @@ def index():
|
||||
# Inspectors see only their own assignments; managers see everything.
|
||||
if current_user.is_inspector:
|
||||
base = base.filter(InspectionSchedule.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(InspectionSchedule.facility_id.in_(fids))
|
||||
if fids else base.filter(False))
|
||||
|
||||
# Counts are computed on the same scoped query, so the badges match what the
|
||||
# viewer can actually open.
|
||||
@@ -385,15 +531,42 @@ def index():
|
||||
|
||||
|
||||
def _form_choices():
|
||||
templates = InspectionTemplate.query.filter_by(active=True).order_by(InspectionTemplate.name).all()
|
||||
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
||||
inspectors = _active_inspectors()
|
||||
"""Lists offered on the schedule form, narrowed to the actor's scope.
|
||||
|
||||
For a Customer Director every list is limited to their own contracts —
|
||||
including the FORM list, so another customer's bespoke form names never
|
||||
appear (phase55 / rule 96). The lists are a UI convenience only; the POST
|
||||
is re-validated by _scope_errors().
|
||||
"""
|
||||
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 (phase55) — the
|
||||
# same union the mobile API builds.
|
||||
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())
|
||||
|
||||
inspectors = _schedulable_inspectors() if customer_scoped else _active_inspectors()
|
||||
return templates, facilities, inspectors
|
||||
|
||||
|
||||
@bp.route('/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@project_manager_required
|
||||
@schedule_manager_required
|
||||
def create():
|
||||
templates, facilities, inspectors = _form_choices()
|
||||
|
||||
@@ -421,6 +594,7 @@ def create():
|
||||
if mode not in _MODES:
|
||||
errors.append('Invalid mode.')
|
||||
errors.extend(_recurrence_errors(request.form, frequency))
|
||||
errors.extend(_scope_errors(template_id, facility_id, inspector_id))
|
||||
|
||||
if errors:
|
||||
for e in errors:
|
||||
@@ -486,11 +660,13 @@ def create():
|
||||
|
||||
@bp.route('/<int:schedule_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@project_manager_required
|
||||
@schedule_manager_required
|
||||
def edit(schedule_id):
|
||||
schedule = db.session.get(InspectionSchedule, schedule_id)
|
||||
if schedule is None:
|
||||
abort(404)
|
||||
if not _schedule_in_scope(schedule):
|
||||
abort(403)
|
||||
templates, facilities, inspectors = _form_choices()
|
||||
|
||||
if request.method == 'POST':
|
||||
@@ -504,6 +680,9 @@ def edit(schedule_id):
|
||||
if frequency not in _FREQUENCIES:
|
||||
frequency = schedule.frequency
|
||||
errors = _recurrence_errors(request.form, frequency)
|
||||
errors.extend(_scope_errors(template_id or schedule.template_id,
|
||||
facility_id or schedule.facility_id,
|
||||
inspector_id))
|
||||
if errors:
|
||||
db.session.rollback()
|
||||
for e in errors:
|
||||
@@ -593,11 +772,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):
|
||||
schedule = db.session.get(InspectionSchedule, schedule_id)
|
||||
if schedule is None:
|
||||
abort(404)
|
||||
if not _schedule_in_scope(schedule):
|
||||
abort(403)
|
||||
name = schedule.name
|
||||
sid = schedule.id
|
||||
db.session.delete(schedule)
|
||||
|
||||
Reference in New Issue
Block a user