From 5df105a9d526e646d7fa1aaf0d769ec5cc7b6dcb Mon Sep 17 00:00:00 2001 From: NguyenND Date: Wed, 19 Aug 2026 10:18:18 -0400 Subject: [PATCH] Aug 19 - Update: now customer Director user can create scheduled task for their staff --- CLAUDE.md | 20 +- app/routes/scheduled_inspections.py | 197 ++++++++++++++++-- app/templates/inspections/list.html | 6 +- app/templates/modern/inspections/list.html | 6 +- app/templates/scheduled_inspections/list.html | 6 +- 5 files changed, 210 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index aebce1e..3c4377f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -540,7 +540,22 @@ All are nullable and **legacy phase36 rows keep NULLs**, falling back to `_add_i - **Instructions (July 2026).** `ScheduledInspectionForm.notes` is labelled **"Instructions"** and `scheduled_inspections/form.html` explains that the text reaches the inspector. The *field name*, `ScheduledInspection.notes`, the `scheduled_inspections.notes` column and the API key `notes` are all unchanged — the rename is a label only (rule 84). The text is surfaced to the inspector in two places: `inspections/execute.html` renders an indigo panel between the header and the form grid, guarded on `inspection.scheduled_inspection and .notes` (NULL for ad-hoc work and for schedules deleted after the start); the iPad shows it on the scheduled row, on the start screen and above the form. - **"Scheduled" badge:** an inspection started from a schedule carries `scheduled_inspection_id`. `Inspection.scheduled_inspection` (relationship, foreign_keys on that column) resolves the source schedule (None if ad-hoc or the schedule was later deleted). The inspection **detail** view header shows a `bi-calendar-check` "Scheduled · " badge, and the inspection **list** shows a compact "Scheduled" pill next to the template name — both gated on `scheduled_inspection_id` being set. -Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_required`; **Start** is the **assigned inspector ONLY** (`sched.inspector_id == current_user.id`) — managers do NOT get a Start button and `GET //start` 403s for anyone who isn't the assignee (the inspection is theirs to do; a manager who must run it assigns it to themselves). The Start button is hidden for non-assignees on both the scheduled-inspections list and the dashboard panel. Inspectors' list/dashboard views are scoped to their own `inspector_id`. +**Customer Directors can plan their own inspections (Aug 2026).** A client asking for an extra clean no longer has to go through us. `@schedule_manager_required` (in the blueprint) = the old `@project_manager_required` set **plus `role == 'customer'`**; `@customer_required` is not reused because this is a capability grant, not a portal gate. + +Everything a Customer Director sees is narrowed to the facilities on their `CustomerAssignment` rows, and **the narrowed choice lists ARE the POST validation** — `SelectField` rejects anything not offered, so this is the security boundary rather than a tidier dropdown: + +| List | Narrowed to | +|---|---| +| facility_id | `get_customer_scope()` — deliberately breaking rule 61's "all facilities" convention, which exists so the UI-only contract selector cannot fail validation; for a customer the scope is the point | +| contract selector (`_active_contracts`) | the contracts behind those facilities | +| template_id | shared forms + those attached to their contracts (phase52), so another customer's bespoke form NAMES never appear (rule 96) | +| inspector_id | inspectors holding an `InspectorAssignment` on their contracts — theirs and ours, never another client's Customer Inspector (rule 93) | + +Plus `_reject_facility_out_of_scope()` re-checks the chosen facility after validation (belt-and-braces: the choice-narrowing is a property of how a list was *built*), `_schedule_in_scope()` 403s edit/delete of a schedule outside their facilities, and `index()` filters the list — with `filter(False)` on an empty scope, never a skipped filter (rule 57's failure mode). + +**Customer Inspectors are excluded** — `_is_customer_director()` tests `role == 'customer'` by equality (rule 89). They *perform* scheduled inspections and are scoped by `InspectorAssignment`; handing them this screen would scope it by the wrong table and show no facilities at all. **Start** is unchanged and still assignee-only, so a Customer Director can plan work but never execute it. + +Management of the underlying routes is otherwise unchanged; **Start** is the **assigned inspector ONLY** (`sched.inspector_id == current_user.id`) — managers do NOT get a Start button and `GET //start` 403s for anyone who isn't the assignee (the inspection is theirs to do; a manager who must run it assigns it to themselves). The Start button is hidden for non-assignees on both the scheduled-inspections list and the dashboard panel. Inspectors' list/dashboard views are scoped to their own `inspector_id`. --- @@ -561,6 +576,7 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi | Templates | ✅ | ✅ | ❌ | ❌ | ❌ | | Inspections (execute) | ✅ | ✅ | ✅ | ✅ | read | | Inspection follow-up (request) | ✅ | ✅ | ❌ | ❌ | ✅ own facilities | +| Scheduled inspections (plan) | ✅ | ✅ | ✅ | ❌ | ✅ own contracts | | Inspection follow-up (clear) | ✅ | ✅ | ❌ | ❌ | ❌ | | Issues (create/assign) | ✅ | ✅ | ✅ | ✅ | ✅ create own | | Issues (quick-assign) | ✅ | ✅ | ❌ | ❌ | ❌ | @@ -606,7 +622,7 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi | `audit` | `/audit` | list (admin only), view, purge | | `reports` | `/reports` | index, facility report, scorecard, CSV/PDF/Excel export, issues-aging, sla-compliance, followup-closure, facility summary PDF | | `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger (accessible via Reports sub-nav) | -| `scheduled_inspections` | `/scheduled-inspections` | list (`?tab=pending\|completed` — Phase 47), new/edit/delete (PM+), `GET //start` (**assigned inspector only** → creates linked inspection; 403 for non-assignees incl. managers), `POST //acknowledge` (**assigned inspector only** → confirms receipt, sets `acknowledged_at`, notifies creator; idempotent — Phase 47), `GET /confirm/` (**login-free** one-click email confirm; signed `itsdangerous` token binding schedule+inspector — Phase 47), `POST /run` (cron reminders, `token=DIGEST_SECRET`) | +| `scheduled_inspections` | `/scheduled-inspections` | list (`?tab=pending\|completed` — Phase 47), new/edit/delete (`@schedule_manager_required` — admin/director/PM/auditor **plus Customer Directors, scoped to their own contracts**), `GET //start` (**assigned inspector only** → creates linked inspection; 403 for non-assignees incl. managers), `POST //acknowledge` (**assigned inspector only** → confirms receipt, sets `acknowledged_at`, notifies creator; idempotent — Phase 47), `GET /confirm/` (**login-free** one-click email confirm; signed `itsdangerous` token binding schedule+inspector — Phase 47), `POST /run` (cron reminders, `token=DIGEST_SECRET`) | | `support` | `/support` | `GET /chat` (loads latest saved session; `?new=1` to start fresh), `POST /chat/message` (AJAX→Groq; **persists** user+assistant turns, returns `session_id`), `GET /my-conversations`, `GET /my-conversations/` (customer chat history), `GET /admin/conversations`, `GET /admin/conversations/` (staff, read-only), `GET /admin/knowledge` + `/new`, `//edit`, `//delete` (admin/director — chatbot knowledge base), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/`, `GET /admin/tickets`, `GET/POST /admin/tickets/` | | `enrollment` | `/enrollment` | **Self-contained onboarding intake — see §24.** `GET/POST /` (**login-free** public form), `GET /admin` (admin inbox), `GET/POST /admin/` (detail + office-use fields), `GET /admin/.json`, `GET /admin/export.csv`. Lives in `app/enrollment/` with its own templates; touches **no** DB table. | | `broadcast` | `/admin/broadcast` | `GET /` (compose + history), `POST /send` (admin-only; fans out one Notification per targeted user) | diff --git a/app/routes/scheduled_inspections.py b/app/routes/scheduled_inspections.py index afa8a30..e511d3c 100644 --- a/app/routes/scheduled_inspections.py +++ b/app/routes/scheduled_inspections.py @@ -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('//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('//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) diff --git a/app/templates/inspections/list.html b/app/templates/inspections/list.html index 3951efa..4f0d2fb 100644 --- a/app/templates/inspections/list.html +++ b/app/templates/inspections/list.html @@ -3,16 +3,18 @@ {% block content %}

Inspections

- {% if current_user.role != 'customer' %} + {# Customer Directors schedule inspections for their own facilities, so the + Scheduled link is theirs too — but starting an ad-hoc inspection is not. #}
Scheduled + {% if current_user.role != 'customer' %} New Inspection + {% endif %}
- {% endif %}
{# Filters #} diff --git a/app/templates/modern/inspections/list.html b/app/templates/modern/inspections/list.html index 15b9283..fb71b5f 100644 --- a/app/templates/modern/inspections/list.html +++ b/app/templates/modern/inspections/list.html @@ -25,16 +25,18 @@
Inspections
- {% if current_user.role != 'customer' %} + {# Customer Directors schedule inspections for their own facilities, so the + Scheduled link is theirs too — but starting an ad-hoc inspection is not. #}
Scheduled + {% if current_user.role != 'customer' %} New Inspection + {% endif %}
- {% endif %} {# ── Filters ──────────────────────────────────────────────────────────── #} diff --git a/app/templates/scheduled_inspections/list.html b/app/templates/scheduled_inspections/list.html index 12397a7..60a5c54 100644 --- a/app/templates/scheduled_inspections/list.html +++ b/app/templates/scheduled_inspections/list.html @@ -11,7 +11,7 @@ Inspections - {% if current_user.role in ['admin','director','project_manager','auditor'] %} + {% if current_user.role in ['admin','director','project_manager','auditor','customer'] %} New Schedule @@ -143,7 +143,7 @@ {% endif %} {% endif %} - {% if current_user.role in ['admin','director','project_manager','auditor'] %} + {% if current_user.role in ['admin','director','project_manager','auditor','customer'] %}
Create one. {% endif %} {% endif %}