Aug 19 - Update: now customer Director user can create scheduled task for their staff

This commit is contained in:
2026-08-19 10:18:18 -04:00
parent d8b5ce201d
commit 5df105a9d5
5 changed files with 210 additions and 25 deletions
+18 -2
View File
@@ -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. - **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 · <frequency>" badge, and the inspection **list** shows a compact "Scheduled" pill next to the template name — both gated on `scheduled_inspection_id` being set. - **"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 · <frequency>" 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 /<id>/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 /<id>/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 | ✅ | ✅ | ❌ | ❌ | ❌ | | Templates | ✅ | ✅ | ❌ | ❌ | ❌ |
| Inspections (execute) | ✅ | ✅ | ✅ | ✅ | read | | Inspections (execute) | ✅ | ✅ | ✅ | ✅ | read |
| Inspection follow-up (request) | ✅ | ✅ | ❌ | ❌ | ✅ own facilities | | Inspection follow-up (request) | ✅ | ✅ | ❌ | ❌ | ✅ own facilities |
| Scheduled inspections (plan) | ✅ | ✅ | ✅ | ❌ | ✅ own contracts |
| Inspection follow-up (clear) | ✅ | ✅ | ❌ | ❌ | ❌ | | Inspection follow-up (clear) | ✅ | ✅ | ❌ | ❌ | ❌ |
| Issues (create/assign) | ✅ | ✅ | ✅ | ✅ | ✅ create own | | Issues (create/assign) | ✅ | ✅ | ✅ | ✅ | ✅ create own |
| Issues (quick-assign) | ✅ | ✅ | ❌ | ❌ | ❌ | | Issues (quick-assign) | ✅ | ✅ | ❌ | ❌ | ❌ |
@@ -606,7 +622,7 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi
| `audit` | `/audit` | list (admin only), view, purge | | `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 | | `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_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 /<id>/start` (**assigned inspector only** → creates linked inspection; 403 for non-assignees incl. managers), `POST /<id>/acknowledge` (**assigned inspector only** → confirms receipt, sets `acknowledged_at`, notifies creator; idempotent — Phase 47), `GET /confirm/<token>` (**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 /<id>/start` (**assigned inspector only** → creates linked inspection; 403 for non-assignees incl. managers), `POST /<id>/acknowledge` (**assigned inspector only** → confirms receipt, sets `acknowledged_at`, notifies creator; idempotent — Phase 47), `GET /confirm/<token>` (**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/<id>` (customer chat history), `GET /admin/conversations`, `GET /admin/conversations/<id>` (staff, read-only), `GET /admin/knowledge` + `/new`, `/<id>/edit`, `/<id>/delete` (admin/director — chatbot knowledge base), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/<id>`, `GET /admin/tickets`, `GET/POST /admin/tickets/<id>` | | `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/<id>` (customer chat history), `GET /admin/conversations`, `GET /admin/conversations/<id>` (staff, read-only), `GET /admin/knowledge` + `/new`, `/<id>/edit`, `/<id>/delete` (admin/director — chatbot knowledge base), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/<id>`, `GET /admin/tickets`, `GET/POST /admin/tickets/<id>` |
| `enrollment` | `/enrollment` | **Self-contained onboarding intake — see §24.** `GET/POST /` (**login-free** public form), `GET /admin` (admin inbox), `GET/POST /admin/<id>` (detail + office-use fields), `GET /admin/<id>.json`, `GET /admin/export.csv`. Lives in `app/enrollment/` with its own templates; touches **no** DB table. | | `enrollment` | `/enrollment` | **Self-contained onboarding intake — see §24.** `GET/POST /` (**login-free** public form), `GET /admin` (admin inbox), `GET/POST /admin/<id>` (detail + office-use fields), `GET /admin/<id>.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) | | `broadcast` | `/admin/broadcast` | `GET /` (compose + history), `POST /send` (admin-only; fans out one Notification per targeted user) |
+178 -13
View File
@@ -3,7 +3,9 @@ app/routes/scheduled_inspections.py
------------------------------------ ------------------------------------
Planned / recurring inspection assignments (phase36). 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 Start (execute the planned inspection): the assigned inspector ONLY (the person
who must do it) not managers. A manager who must do it) not managers. A manager
who needs to run it assigns it to themselves. 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.project import Project
from app.models.user import User from app.models.user import User
from app.utils.forms import ScheduledInspectionForm 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.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
from app.utils.time_utils import now_eastern from app.utils.time_utils import now_eastern
from app.utils.notifications import notify 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): def _populate_choices(form):
# facility_id choices are ALL active facilities so POST validation passes # facility_id choices are ALL active facilities so POST validation passes
# regardless of which contract the UI-only contract selector had chosen # regardless of which contract the UI-only contract selector had chosen
# (CLAUDE.md rule 61). The contract selector narrows the list client-side. # (CLAUDE.md rule 61). The contract selector narrows the list client-side.
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).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 templates = (InspectionTemplate.query
.filter_by(active=True).order_by(InspectionTemplate.name).all()) .filter_by(active=True).order_by(InspectionTemplate.name).all())
inspectors = (User.query.filter(User.role.in_(User.INSPECTOR_ROLES),
insp_q = User.query.filter(User.role.in_(User.INSPECTOR_ROLES),
User.active == True) User.active == True)
.order_by(User.username).all()) 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.facility_id.choices = [(f.id, f.name) for f in facilities]
form.template_id.choices = [(t.id, t.name) for t in templates] 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] form.inspector_id.choices = [(u.id, u.display_name) for u in inspectors]
def _active_contracts(): 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): def _notify_assignee(sched, reassigned=False):
@@ -192,6 +318,29 @@ def _reject_if_past_end_date(sched, form):
return True 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): def _reject_template_not_on_contract(form):
"""True (and a form error set) if the chosen form isn't usable at the """True (and a form error set) if the chosen form isn't usable at the
chosen facility (phase52). chosen facility (phase52).
@@ -246,8 +395,9 @@ def _selected_project_id(form):
@bp.route('/') @bp.route('/')
@login_required @login_required
def index(): def index():
if current_user.role == 'customer': # Customer Directors now plan inspections for their own facilities, so they
abort(403) # 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 # Two tabs (phase47): Pending = schedules still producing occurrences
# (active); Completed = closed schedules (fulfilled one-times, ended # (active); Completed = closed schedules (fulfilled one-times, ended
@@ -264,6 +414,13 @@ def index():
# Inspectors see only their own assignments; managers see everything. # Inspectors see only their own assignments; managers see everything.
if current_user.is_inspector: if current_user.is_inspector:
base = base.filter(ScheduledInspection.inspector_id == current_user.id) 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() pending_count = base.filter(ScheduledInspection.active.is_(True)).count()
completed_count = base.filter(ScheduledInspection.active.is_(False)).count() completed_count = base.filter(ScheduledInspection.active.is_(False)).count()
@@ -288,7 +445,7 @@ def index():
@bp.route('/new', methods=['GET', 'POST']) @bp.route('/new', methods=['GET', 'POST'])
@login_required @login_required
@project_manager_required @schedule_manager_required
def create(): def create():
form = ScheduledInspectionForm() form = ScheduledInspectionForm()
_populate_choices(form) _populate_choices(form)
@@ -299,7 +456,9 @@ def create():
if not form.next_due_date.data: if not form.next_due_date.data:
form.next_due_date.data = now_eastern().date() 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( sched = ScheduledInspection(
facility_id = form.facility_id.data, facility_id = form.facility_id.data,
template_id = form.template_id.data, template_id = form.template_id.data,
@@ -342,11 +501,13 @@ def create():
@bp.route('/<int:schedule_id>/edit', methods=['GET', 'POST']) @bp.route('/<int:schedule_id>/edit', methods=['GET', 'POST'])
@login_required @login_required
@project_manager_required @schedule_manager_required
def edit(schedule_id): def edit(schedule_id):
sched = db.session.get(ScheduledInspection, schedule_id) sched = db.session.get(ScheduledInspection, schedule_id)
if sched is None: if sched is None:
abort(404) abort(404)
if not _schedule_in_scope(sched):
abort(403)
form = ScheduledInspectionForm(obj=sched) form = ScheduledInspectionForm(obj=sched)
_populate_choices(form) _populate_choices(form)
form.next_due_date.label.text = 'Next Due Date' form.next_due_date.label.text = 'Next Due Date'
@@ -356,7 +517,9 @@ def edit(schedule_id):
form.weekdays.data = sched.weekday_list form.weekdays.data = sched.weekday_list
form.month_mode.data = sched.month_mode or MONTH_MODE_DAY 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 old_inspector_id = sched.inspector_id
sched.facility_id = form.facility_id.data sched.facility_id = form.facility_id.data
sched.template_id = form.template_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']) @bp.route('/<int:schedule_id>/delete', methods=['POST'])
@login_required @login_required
@project_manager_required @schedule_manager_required
def delete(schedule_id): def delete(schedule_id):
sched = db.session.get(ScheduledInspection, schedule_id) sched = db.session.get(ScheduledInspection, schedule_id)
if sched is None: if sched is None:
abort(404) 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 "?"}' label = f'{sched.template.name if sched.template else "?"} @ {sched.facility.name if sched.facility else "?"}'
sid = sched.id sid = sched.id
db.session.delete(sched) db.session.delete(sched)
+4 -2
View File
@@ -3,16 +3,18 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-clipboard-data"></i> Inspections</h2> <h2><i class="bi bi-clipboard-data"></i> Inspections</h2>
{% 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. #}
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<a href="{{ url_for('scheduled_inspections.index') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('scheduled_inspections.index') }}" class="btn btn-outline-secondary">
<i class="bi bi-calendar-check"></i> Scheduled <i class="bi bi-calendar-check"></i> Scheduled
</a> </a>
{% if current_user.role != 'customer' %}
<a href="{{ url_for('inspections.start') }}" class="btn btn-primary"> <a href="{{ url_for('inspections.start') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> New Inspection <i class="bi bi-plus-circle"></i> New Inspection
</a> </a>
</div>
{% endif %} {% endif %}
</div>
</div> </div>
{# Filters #} {# Filters #}
+4 -2
View File
@@ -25,16 +25,18 @@
<div> <div>
<div class="jqc-page-title">Inspections</div> <div class="jqc-page-title">Inspections</div>
</div> </div>
{% 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. #}
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<a href="{{ url_for('scheduled_inspections.index') }}" class="btn btn-outline-primary"> <a href="{{ url_for('scheduled_inspections.index') }}" class="btn btn-outline-primary">
<i class="bi bi-calendar-check"></i> Scheduled <i class="bi bi-calendar-check"></i> Scheduled
</a> </a>
{% if current_user.role != 'customer' %}
<a href="{{ url_for('inspections.start') }}" class="btn btn-primary"> <a href="{{ url_for('inspections.start') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> New Inspection <i class="bi bi-plus-circle"></i> New Inspection
</a> </a>
</div>
{% endif %} {% endif %}
</div>
</div> </div>
{# ── Filters ──────────────────────────────────────────────────────────── #} {# ── Filters ──────────────────────────────────────────────────────────── #}
@@ -11,7 +11,7 @@
<a href="{{ url_for('inspections.index') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('inspections.index') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Inspections <i class="bi bi-arrow-left"></i> Inspections
</a> </a>
{% if current_user.role in ['admin','director','project_manager','auditor'] %} {% if current_user.role in ['admin','director','project_manager','auditor','customer'] %}
<a href="{{ url_for('scheduled_inspections.create') }}" class="btn btn-primary"> <a href="{{ url_for('scheduled_inspections.create') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> New Schedule <i class="bi bi-plus-circle"></i> New Schedule
</a> </a>
@@ -143,7 +143,7 @@
</a> </a>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if current_user.role in ['admin','director','project_manager','auditor'] %} {% if current_user.role in ['admin','director','project_manager','auditor','customer'] %}
<a href="{{ url_for('scheduled_inspections.edit', schedule_id=s.id) }}" <a href="{{ url_for('scheduled_inspections.edit', schedule_id=s.id) }}"
class="btn btn-sm btn-outline-primary"><i class="bi bi-pencil"></i></a> class="btn btn-sm btn-outline-primary"><i class="bi bi-pencil"></i></a>
<form method="POST" class="d-inline" <form method="POST" class="d-inline"
@@ -165,7 +165,7 @@
No completed scheduled inspections yet. No completed scheduled inspections yet.
{% else %} {% else %}
No pending scheduled inspections. No pending scheduled inspections.
{% if current_user.role in ['admin','director','project_manager','auditor'] %} {% if current_user.role in ['admin','director','project_manager','auditor','customer'] %}
<a href="{{ url_for('scheduled_inspections.create') }}">Create one</a>. <a href="{{ url_for('scheduled_inspections.create') }}">Create one</a>.
{% endif %} {% endif %}
{% endif %} {% endif %}