Aug 19 - Update: now customer Director user can create scheduled task for their staff
This commit is contained in:
@@ -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 · <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 | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| 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 /<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>` |
|
||||
| `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) |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -3,16 +3,18 @@
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<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">
|
||||
<a href="{{ url_for('scheduled_inspections.index') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-calendar-check"></i> Scheduled
|
||||
</a>
|
||||
{% if current_user.role != 'customer' %}
|
||||
<a href="{{ url_for('inspections.start') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> New Inspection
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Filters #}
|
||||
|
||||
@@ -25,16 +25,18 @@
|
||||
<div>
|
||||
<div class="jqc-page-title">Inspections</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">
|
||||
<a href="{{ url_for('scheduled_inspections.index') }}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-calendar-check"></i> Scheduled
|
||||
</a>
|
||||
{% if current_user.role != 'customer' %}
|
||||
<a href="{{ url_for('inspections.start') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> New Inspection
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Filters ──────────────────────────────────────────────────────────── #}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<a href="{{ url_for('inspections.index') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Inspections
|
||||
</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">
|
||||
<i class="bi bi-plus-circle"></i> New Schedule
|
||||
</a>
|
||||
@@ -143,7 +143,7 @@
|
||||
</a>
|
||||
{% 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) }}"
|
||||
class="btn btn-sm btn-outline-primary"><i class="bi bi-pencil"></i></a>
|
||||
<form method="POST" class="d-inline"
|
||||
@@ -165,7 +165,7 @@
|
||||
No completed scheduled inspections yet.
|
||||
{% else %}
|
||||
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>.
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user