Aug 19 - Update code to catch up with ST

This commit is contained in:
2026-08-19 14:05:18 -04:00
parent c9984e7ae6
commit 12141c2f75
52 changed files with 3321 additions and 342 deletions
+189 -8
View File
@@ -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)