Jul 15 - Add Auditor user role

This commit is contained in:
2026-07-15 13:47:59 -04:00
parent df547eefc2
commit fcb959900f
26 changed files with 152 additions and 56 deletions
+23 -5
View File
@@ -2,7 +2,7 @@
> **Audience:** AI assistants and developers working on this codebase. > **Audience:** AI assistants and developers working on this codebase.
> **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions. > **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions.
> **Last reviewed:** July 2026 (Phase 19 complete + mobile API gap-fill Phases AE + customer UI refinements + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector performance Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + date/ID filters + Reports expansion Phases R1R4 + Phase 24 issue_created notify defaults + Phase 25 inspection GPS + Phase 26 issue vendor fields + Phase 27 facility score alerts + Phase 28 inspection-notify fix + Phase 29 admin broadcasts + Phases 3032 device registry consolidation + ProxyFix reverse-proxy fix + Phase 33 per-contract notification recipients + grouped Admin nav dropdown + forgot-password case-insensitive lookup & email normalization + transactional email sender/branding fix + Phase 34 facility QR public pages & report-a-problem + Phase 35 issue handler_type (our staff / facility / vendor) + Phase 36 scheduled inspections + Phase 37 support chat persistence + Phase 38 support knowledge base + Phase 39 per-area QR public pages) > **Last reviewed:** July 2026 (Phase 19 complete + mobile API gap-fill Phases AE + customer UI refinements + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector performance Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + date/ID filters + Reports expansion Phases R1R4 + Phase 24 issue_created notify defaults + Phase 25 inspection GPS + Phase 26 issue vendor fields + Phase 27 facility score alerts + Phase 28 inspection-notify fix + Phase 29 admin broadcasts + Phases 3032 device registry consolidation + ProxyFix reverse-proxy fix + Phase 33 per-contract notification recipients + grouped Admin nav dropdown + forgot-password case-insensitive lookup & email normalization + transactional email sender/branding fix + Phase 34 facility QR public pages & report-a-problem + Phase 35 issue handler_type (our staff / facility / vendor) + Phase 36 scheduled inspections + Phase 37 support chat persistence + Phase 38 support knowledge base + Phase 39 per-area QR public pages + Phase 40 auditor role)
--- ---
@@ -191,7 +191,9 @@ users: id, username (unique, indexed), full_name, email (unique, indexed),
password_set, set_password_token (indexed), set_password_token_expires password_set, set_password_token (indexed), set_password_token_expires
``` ```
**Role ENUM:** `admin`, `director`, `inspector`, `project_manager`, `customer` **Role ENUM:** `admin`, `director`, `inspector`, `project_manager`, `customer`, `auditor`
**`auditor` (Phase 40):** A staff role with the **same access as `project_manager`** (it is included in `@project_manager_required` and everywhere `project_manager` is checked) **plus full issue-management powers** — create, assign, quick-assign, handler/vendor triage, request-verification, and verify/bulk-verify/verification-queue (via the new `@issue_manager_required` decorator). **Auditor does NOT get issue deletion** (that stays admin/director via `@supervisor_required`), nor any other admin/director-only area PM lacks (users, audit trail, notification matrix, customers, templates). Auditors are **assignable** as an issue/inspection assignee; **admin was removed** from the assignable set at the same time (assignee dropdowns are now `director`/`inspector`/`auditor`, plus `project_manager` on the inspection flag-issue dropdown). The issue-update route defensively keeps any pre-existing out-of-set assignee (e.g. a legacy admin assignment) in the dropdown so saving never silently unassigns. Auditor **has mobile-API access** — it is included in the `_ALLOWED_ROLES` set of every `app/api/*` module (comments, inspections, issues, photos, scheduled, stats, templates), so the iPad app accepts auditor logins. In every API endpoint that scopes by role, auditor falls into the non-inspector/non-customer (privileged) branch — org-wide data, same as admin/director/PM.
**Key property:** `display_name``full_name.strip()` or falls back to `username`. **Key property:** `display_name``full_name.strip()` or falls back to `username`.
@@ -426,6 +428,8 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi
## 6. Role & Permission Matrix ## 6. Role & Permission Matrix
**`auditor` reads as a `project_manager` column** below, with these overrides: **Issues (quick-assign)** ✅, **Issue verification** ✅, and it appears in the **Issues (create/assign)** and **Issue verification** rows as ✅. It never gains issue *delete* or any admin/director-only row PM lacks. See the `auditor` note in §5.
| Area | admin | director | project_manager | inspector | customer | | Area | admin | director | project_manager | inspector | customer |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| Dashboard | ✅ full | ✅ full | ✅ full | ✅ limited | ✅ scoped | | Dashboard | ✅ full | ✅ full | ✅ full | ✅ limited | ✅ scoped |
@@ -454,7 +458,8 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi
```python ```python
@admin_required # role == 'admin' only @admin_required # role == 'admin' only
@supervisor_required # role in ('admin', 'director') — name kept to avoid touching 30+ routes @supervisor_required # role in ('admin', 'director') — name kept to avoid touching 30+ routes
@project_manager_required # role in ('admin', 'director', 'project_manager') @project_manager_required # role in ('admin', 'director', 'project_manager', 'auditor')
@issue_manager_required # role in ('admin', 'director', 'auditor') — issue verification (NOT delete)
@customer_required # role == 'customer' only @customer_required # role == 'customer' only
``` ```
@@ -472,7 +477,7 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi
| `customers` | `/customers` | list, invite, set-password, manage, import CSV | | `customers` | `/customers` | list, invite, set-password, manage, import CSV |
| `inspections` | `/inspections` | list, start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect, upload-photo (AJAX) | | `inspections` | `/inspections` | list, start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect, upload-photo (AJAX) |
| `templates` | `/templates` | list, create, edit, delete, form editor, preview | | `templates` | `/templates` | list, create, edit, delete, form editor, preview |
| `issues` | `/issues` | list, view, create, update, verify, comment, follow/unfollow, verification queue, bulk-verify, delete, quick-assign | | `issues` | `/issues` | list, view, create, update, verify, comment, follow/unfollow, verification queue, bulk-verify, delete, quick-assign. **verify / bulk-verify / verification-queue are `@issue_manager_required` (admin/director/auditor); delete stays `@supervisor_required` (admin/director).** |
| `notifications` | `/notifications` | list, mark-read, preferences, send-digest (cron), check-sla (cron), cleanup-tokens (cron) | | `notifications` | `/notifications` | list, mark-read, preferences, send-digest (cron), check-sla (cron), cleanup-tokens (cron) |
| `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 |
@@ -806,7 +811,8 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif
→ phase36_scheduled_insp → phase36_scheduled_insp
→ phase37_support_chat → phase37_support_chat
→ phase38_support_knowledge → phase38_support_knowledge
→ phase39_area_public_token ← HEAD → phase39_area_public_token
→ phase40_auditor_role ← HEAD
``` ```
### phase21_performance_indexes ### phase21_performance_indexes
@@ -955,6 +961,16 @@ flask db upgrade # adds + backfills areas.public_token
sudo systemctl restart gunicorn sudo systemctl restart gunicorn
``` ```
### phase40_auditor_role
Revision id `phase40_auditor_role`. Adds the `auditor` value to the `users.role` ENUM (`ALTER TABLE users MODIFY COLUMN role ENUM(...,'auditor') NOT NULL`). This is a **pure ENUM expansion** (adds a value, removes/migrates nothing), so the 3-step ENUM protocol does not apply and the `MODIFY` is idempotent — safe to re-run. `downgrade()` reassigns any `auditor` rows to `project_manager` before contracting the ENUM. Backs the new Auditor role — see the `auditor` note in §5 and the `@issue_manager_required` decorator in §6.
**Deploy order:**
```bash
flask db upgrade # expands users.role ENUM with 'auditor'
sudo systemctl restart gunicorn
```
**Deploy order for phases 2432:** **Deploy order for phases 2432:**
```bash ```bash
flask db upgrade flask db upgrade
@@ -1300,6 +1316,8 @@ timeout = 30
| 76 | **Transactional email `From` must be an SMTP-authorized identity, per-domain branding via display name only** | Reset-password sends from `MAIL_DEFAULT_SENDER`; customer invite sends from `branded_sender()` = `(per-domain display name, authorized address)`. A per-host `noreply@<subdomain>` sender is accepted by the relay then dropped by SPF/DMARC. See rule 64 and §8 `mail_utils.py`. | | 76 | **Transactional email `From` must be an SMTP-authorized identity, per-domain branding via display name only** | Reset-password sends from `MAIL_DEFAULT_SENDER`; customer invite sends from `branded_sender()` = `(per-domain display name, authorized address)`. A per-host `noreply@<subdomain>` sender is accepted by the relay then dropped by SPF/DMARC. See rule 64 and §8 `mail_utils.py`. |
| 77 | **`GET /api/v1/scheduled-inspections` is inspector-scoped by `inspector_id`, admin/director/PM see all** | New `app/api/scheduled.py` blueprint. Register in `app/api/__init__.py` AND `csrf.exempt(_api_scheduled_bp)` in `app/__init__.py` — the child-blueprint CSRF exemption never cascades from the parent. Read-only; do not add write/fulfil endpoints here (the schedule lifecycle stays in `routes/scheduled_inspections.py`). | | 77 | **`GET /api/v1/scheduled-inspections` is inspector-scoped by `inspector_id`, admin/director/PM see all** | New `app/api/scheduled.py` blueprint. Register in `app/api/__init__.py` AND `csrf.exempt(_api_scheduled_bp)` in `app/__init__.py` — the child-blueprint CSRF exemption never cascades from the parent. Read-only; do not add write/fulfil endpoints here (the schedule lifecycle stays in `routes/scheduled_inspections.py`). |
| 78 | **`PATCH /api/v1/issues/<id>/handler` allows the inspector on purpose — do NOT align it to the web form's admin/director/PM restriction** | The iPad lets the assigned inspector set "Handled By" from the field, scoped via `get_inspector_scope()` (403 if the issue's facility isn't contracted). This is a deliberate divergence from the web form. `_issue_payload()` must keep returning all 8 handler fields (`handler_type`, `handler_label`, `facility_handler_*`, `vendor_*`) or the iPad's "Handled By" panel silently blanks — same failure mode as rule 40. | | 78 | **`PATCH /api/v1/issues/<id>/handler` allows the inspector on purpose — do NOT align it to the web form's admin/director/PM restriction** | The iPad lets the assigned inspector set "Handled By" from the field, scoped via `get_inspector_scope()` (403 if the issue's facility isn't contracted). This is a deliberate divergence from the web form. `_issue_payload()` must keep returning all 8 handler fields (`handler_type`, `handler_label`, `facility_handler_*`, `vendor_*`) or the iPad's "Handled By" panel silently blanks — same failure mode as rule 40. |
| 79 | **`auditor` = `project_manager` access + issue management, minus delete — keep the two decorators distinct** | Auditor is added to `@project_manager_required` (PM baseline) and to every `project_manager` role check in routes/templates. Its *extra* issue powers (verify/bulk-verify/verification-queue) go through the separate `@issue_manager_required` (admin/director/auditor). Issue **delete** stays `@supervisor_required` — never add auditor there. When adding a new PM-level gate, include `auditor`; when adding a director-only or delete-level gate, do not. The three issue **delete** template gates (spaced `['admin', 'director']` in `issues/list.html` + `issues/view.html`) are deliberately left without auditor. Auditor is also in the `_ALLOWED_ROLES` set of every `app/api/*` module — a **new** API blueprint's `_ALLOWED_ROLES` must include `auditor` for PM parity. |
| 80 | **Assignee dropdowns are `director`/`inspector`/`auditor` (admin removed, auditor added)** | The issue/inspection assignee `<select>`s query `User.role.in_([...])` — admin was removed and auditor added (the inspection flag-issue list also keeps `project_manager`). These lists control who can be *assigned*, distinct from who can *edit*. The issue-update route (`issues.view`) defensively appends any current `assigned_to` who is not in the set (e.g. a legacy admin assignment) to `form.assigned_to.choices` so saving the form never silently unassigns them. Do not remove that guard. |
--- ---
+1 -1
View File
@@ -29,7 +29,7 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_comments', __name__) bp = Blueprint('api_comments', __name__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'} _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
def _comment_payload(comment: IssueComment) -> dict: def _comment_payload(comment: IssueComment) -> dict:
+1 -1
View File
@@ -34,7 +34,7 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_inspections', __name__) bp = Blueprint('api_inspections', __name__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'} _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
def _merge_form_data(existing: dict, incoming: dict) -> dict: def _merge_form_data(existing: dict, incoming: dict) -> dict:
+1 -1
View File
@@ -41,7 +41,7 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_issues', __name__) bp = Blueprint('api_issues', __name__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'} _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
_VALID_SEVERITY = {'low', 'medium', 'high', 'critical'} _VALID_SEVERITY = {'low', 'medium', 'high', 'critical'}
_VALID_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'} _VALID_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'}
_VALID_HANDLERS = {'internal', 'facility', 'vendor'} _VALID_HANDLERS = {'internal', 'facility', 'vendor'}
+1 -1
View File
@@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_photos', __name__) bp = Blueprint('api_photos', __name__)
_ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'} _ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'}
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'} _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
def _allowed_file(filename: str) -> bool: def _allowed_file(filename: str) -> bool:
+1 -1
View File
@@ -28,7 +28,7 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_scheduled', __name__) bp = Blueprint('api_scheduled', __name__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'} _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
def _scheduled_payload(s): def _scheduled_payload(s):
+1 -1
View File
@@ -40,7 +40,7 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_stats', __name__) bp = Blueprint('api_stats', __name__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'} _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
@bp.route('/stats/dashboard', methods=['GET']) @bp.route('/stats/dashboard', methods=['GET'])
+1 -1
View File
@@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_templates', __name__) bp = Blueprint('api_templates', __name__)
# Customer role cannot access template data — inspectors and above only # Customer role cannot access template data — inspectors and above only
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'} _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
def _template_summary_payload(template: InspectionTemplate) -> dict: def _template_summary_payload(template: InspectionTemplate) -> dict:
+1
View File
@@ -40,6 +40,7 @@ MATRIX_ROLES = [
('director', 'Director'), ('director', 'Director'),
('inspector', 'Inspector'), ('inspector', 'Inspector'),
('project_manager', 'Project Manager'), ('project_manager', 'Project Manager'),
('auditor', 'Auditor'),
('customer', 'Customer'), ('customer', 'Customer'),
('custom', 'Custom Recipients'), ('custom', 'Custom Recipients'),
] ]
+1 -1
View File
@@ -19,7 +19,7 @@ class User(UserMixin, db.Model):
role = db.Column( role = db.Column(
# Phase 11 migration complete — 'supervisor' removed from both the DB # Phase 11 migration complete — 'supervisor' removed from both the DB
# ENUM and this Python-side declaration. Director is the canonical role. # ENUM and this Python-side declaration. Director is the canonical role.
db.Enum('admin', 'director', 'inspector', 'project_manager', 'customer'), db.Enum('admin', 'director', 'inspector', 'project_manager', 'customer', 'auditor'),
nullable=False nullable=False
) )
created_at = db.Column(db.DateTime, default=now_eastern) created_at = db.Column(db.DateTime, default=now_eastern)
+3 -2
View File
@@ -39,6 +39,7 @@ def index():
is_privileged = current_user.role in ['admin', 'director'] is_privileged = current_user.role in ['admin', 'director']
is_customer = current_user.role == 'customer' is_customer = current_user.role == 'customer'
is_project_manager = current_user.role == 'project_manager' is_project_manager = current_user.role == 'project_manager'
is_auditor = current_user.role == 'auditor'
# Resolve facility scope # Resolve facility scope
customer_facility_ids = get_customer_scope(current_user) # None for non-customers customer_facility_ids = get_customer_scope(current_user) # None for non-customers
@@ -309,9 +310,9 @@ def index():
unassigned_open = len(unassigned_all) unassigned_open = len(unassigned_all)
unassigned_handler = _handler_split(unassigned_all) unassigned_handler = _handler_split(unassigned_all)
# ── Inspector activity today (admin / director / PM only) ───────────────── # ── Inspector activity today (admin / director / PM / auditor only) ───────
inspector_activity = [] inspector_activity = []
if is_privileged or is_project_manager: if is_privileged or is_project_manager or is_auditor:
active_inspectors = ( active_inspectors = (
User.query User.query
.filter_by(role='inspector', active=True) .filter_by(role='inspector', active=True)
+1 -1
View File
@@ -608,7 +608,7 @@ def execute(inspection_id):
return redirect(url_for('inspections.execute', inspection_id=inspection_id)) return redirect(url_for('inspections.execute', inspection_id=inspection_id))
staff_for_flag_issue = User.query.filter( staff_for_flag_issue = User.query.filter(
User.role.in_(['admin', 'director', 'inspector', 'project_manager']), User.role.in_(['director', 'inspector', 'project_manager', 'auditor']),
User.active == True, User.active == True,
).order_by(User.full_name, User.username).all() ).order_by(User.full_name, User.username).all()
+21 -14
View File
@@ -15,7 +15,7 @@ from app.models.notification import (
EVENT_CUSTOMER_ISSUE_UPDATED, EVENT_CUSTOMER_ISSUE_UPDATED,
) )
from app.utils.forms import IssueForm, IssueUpdateForm from app.utils.forms import IssueForm, IssueUpdateForm
from app.utils.decorators import supervisor_required from app.utils.decorators import supervisor_required, issue_manager_required
from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT
from app.utils.pdf_export import generate_issues_list_pdf from app.utils.pdf_export import generate_issues_list_pdf
@@ -344,7 +344,7 @@ def index():
# Staff for quick-assign dropdown — same roles as the full issue form # Staff for quick-assign dropdown — same roles as the full issue form
staff = User.query.filter( staff = User.query.filter(
User.role.in_(['admin', 'director', 'inspector']), User.active == True User.role.in_(['director', 'inspector', 'auditor']), User.active == True
).order_by(User.username).all() ).order_by(User.username).all()
# Reporters dropdown — users who have actually filed at least one issue # Reporters dropdown — users who have actually filed at least one issue
@@ -419,7 +419,14 @@ def view(issue_id):
return redirect(url_for('issues.view', issue_id=issue_id)) return redirect(url_for('issues.view', issue_id=issue_id))
form = IssueUpdateForm(obj=issue) form = IssueUpdateForm(obj=issue)
staff = User.query.filter(User.role.in_(['admin', 'director', 'inspector'])).order_by(User.username).all() staff = User.query.filter(User.role.in_(['director', 'inspector', 'auditor'])).order_by(User.username).all()
# Preserve any pre-existing assignee who is no longer in the assignable set
# (e.g. an admin assigned before admins were removed from the dropdown) so
# saving the form doesn't silently unassign them.
if issue.assigned_to and issue.assigned_to not in [u.id for u in staff]:
current_assignee = db.session.get(User, issue.assigned_to)
if current_assignee:
staff.append(current_assignee)
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.display_name) for u in staff] form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.display_name) for u in staff]
form.status.data = form.status.data or issue.status form.status.data = form.status.data or issue.status
@@ -429,7 +436,7 @@ def view(issue_id):
issue.status = form.status.data issue.status = form.status.data
if current_user.role in ['admin', 'director']: if current_user.role in ['admin', 'director', 'auditor']:
issue.assigned_to = form.assigned_to.data or None issue.assigned_to = form.assigned_to.data or None
if form.status.data == 'resolved' and not issue.resolved_at: if form.status.data == 'resolved' and not issue.resolved_at:
@@ -447,8 +454,8 @@ def view(issue_id):
issue.result_notes = form.result_notes.data or None issue.result_notes = form.result_notes.data or None
# Handler assignment (who handles it) + vendor/facility details — # Handler assignment (who handles it) + vendor/facility details —
# admin, director, project_manager only. # admin, director, project_manager, auditor only.
if current_user.role in ('admin', 'director', 'project_manager'): if current_user.role in ('admin', 'director', 'project_manager', 'auditor'):
handler = form.handler_type.data or 'internal' handler = form.handler_type.data or 'internal'
if handler not in ('internal', 'facility', 'vendor'): if handler not in ('internal', 'facility', 'vendor'):
handler = 'internal' handler = 'internal'
@@ -690,7 +697,7 @@ def unfollow(issue_id):
@bp.route('/new', methods=['GET', 'POST']) @bp.route('/new', methods=['GET', 'POST'])
@login_required @login_required
def create(): def create():
if current_user.role not in ('admin', 'director', 'customer'): if current_user.role not in ('admin', 'director', 'customer', 'auditor'):
abort(403) abort(403)
from app.models.project import Project, CustomerAssignment from app.models.project import Project, CustomerAssignment
@@ -712,7 +719,7 @@ def create():
else: else:
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all() facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
projects = Project.query.filter_by(active=True).order_by(Project.name).all() projects = Project.query.filter_by(active=True).order_by(Project.name).all()
staff = User.query.filter(User.role.in_(['admin', 'director', 'inspector'])).order_by(User.username).all() staff = User.query.filter(User.role.in_(['director', 'inspector', 'auditor'])).order_by(User.username).all()
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.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.display_name) for u in staff] form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.display_name) for u in staff]
@@ -812,7 +819,7 @@ def create():
@bp.route('/<int:issue_id>/verify', methods=['POST']) @bp.route('/<int:issue_id>/verify', methods=['POST'])
@login_required @login_required
@supervisor_required @issue_manager_required
def verify(issue_id): def verify(issue_id):
"""Supervisor sign-off: confirms resolution is satisfactory and closes the issue.""" """Supervisor sign-off: confirms resolution is satisfactory and closes the issue."""
issue = db.session.get(Issue, issue_id) issue = db.session.get(Issue, issue_id)
@@ -846,7 +853,7 @@ def verify(issue_id):
@bp.route('/bulk-verify', methods=['POST']) @bp.route('/bulk-verify', methods=['POST'])
@login_required @login_required
@supervisor_required @issue_manager_required
def bulk_verify(): def bulk_verify():
"""Verify multiple pending-verification issues in a single action.""" """Verify multiple pending-verification issues in a single action."""
issue_ids = request.form.getlist('issue_ids', type=int) issue_ids = request.form.getlist('issue_ids', type=int)
@@ -891,9 +898,9 @@ def request_verification(issue_id):
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('issues.index')) return redirect(url_for('issues.index'))
# Only the assignee, director, or admin can request verification # Only the assignee, director, admin, or auditor can request verification
can_act = ( can_act = (
current_user.role in ['admin', 'director'] current_user.role in ['admin', 'director', 'auditor']
or issue.assigned_to == current_user.id or issue.assigned_to == current_user.id
) )
if not can_act: if not can_act:
@@ -936,7 +943,7 @@ def request_verification(issue_id):
@bp.route('/verification-queue') @bp.route('/verification-queue')
@login_required @login_required
@supervisor_required @issue_manager_required
def verification_queue(): def verification_queue():
"""Supervisor queue of all issues awaiting verification, grouped by facility.""" """Supervisor queue of all issues awaiting verification, grouped by facility."""
from app.models.facility import Facility, Area from app.models.facility import Facility, Area
@@ -1032,7 +1039,7 @@ def delete(issue_id):
@login_required @login_required
def quick_assign(issue_id): def quick_assign(issue_id):
"""Inline assignee update from the issues list — returns JSON.""" """Inline assignee update from the issues list — returns JSON."""
if current_user.role not in ('admin', 'director'): if current_user.role not in ('admin', 'director', 'auditor'):
return jsonify({'ok': False, 'error': 'Permission denied'}), 403 return jsonify({'ok': False, 'error': 'Permission denied'}), 403
issue = db.session.get(Issue, issue_id) issue = db.session.get(Issue, issue_id)
+1 -1
View File
@@ -37,7 +37,7 @@
<td>{{ user.full_name or '—' }}</td> <td>{{ user.full_name or '—' }}</td>
<td>{{ user.email }}</td> <td>{{ user.email }}</td>
<td> <td>
<span class="badge bg-{% if user.role == 'admin' %}danger{% elif user.role == 'director' %}warning{% elif user.role == 'project_manager' %}primary{% elif user.role == 'customer' %}success{% else %}info{% endif %}"> <span class="badge bg-{% if user.role == 'admin' %}danger{% elif user.role == 'director' %}warning{% elif user.role == 'project_manager' %}primary{% elif user.role == 'auditor' %}secondary{% elif user.role == 'customer' %}success{% else %}info{% endif %}">
{{ user.role.replace('_',' ')|title }} {{ user.role.replace('_',' ')|title }}
</span> </span>
</td> </td>
+2 -2
View File
@@ -119,7 +119,7 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('reports.') or request.endpoint.startswith('scheduled_reports.')) }}" href="{{ url_for('reports.index') }}">Reports</a> <a class="nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('reports.') or request.endpoint.startswith('scheduled_reports.')) }}" href="{{ url_for('reports.index') }}">Reports</a>
</li> </li>
{% if current_user.role in ['admin', 'director', 'project_manager'] %} {% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}" href="{{ url_for('projects.index') }}">Contracts</a> <a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}" href="{{ url_for('projects.index') }}">Contracts</a>
</li> </li>
@@ -138,7 +138,7 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('issues.') and request.endpoint != 'issues.verification_queue' }}" href="{{ url_for('issues.index') }}">Issues</a> <a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('issues.') and request.endpoint != 'issues.verification_queue' }}" href="{{ url_for('issues.index') }}">Issues</a>
</li> </li>
{% if current_user.role in ['admin', 'director'] %} {% if current_user.role in ['admin', 'director', 'auditor'] %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link d-flex align-items-center gap-1 {{ 'active' if request.endpoint == 'issues.verification_queue' }}" <a class="nav-link d-flex align-items-center gap-1 {{ 'active' if request.endpoint == 'issues.verification_queue' }}"
href="{{ url_for('issues.verification_queue') }}"> href="{{ url_for('issues.verification_queue') }}">
+2 -2
View File
@@ -23,7 +23,7 @@
<div class="row mb-3 align-items-center"> <div class="row mb-3 align-items-center">
<div class="col"> <div class="col">
<h2 class="mb-0">Welcome, {{ current_user.display_name }}!</h2> <h2 class="mb-0">Welcome, {{ current_user.display_name }}!</h2>
<span class="badge bg-{% if current_user.role == 'admin' %}danger{% elif current_user.role == 'director' %}warning{% elif current_user.role == 'project_manager' %}primary{% elif current_user.role == 'customer' %}success{% else %}info{% endif %} mt-1"> <span class="badge bg-{% if current_user.role == 'admin' %}danger{% elif current_user.role == 'director' %}warning{% elif current_user.role == 'project_manager' %}primary{% elif current_user.role == 'auditor' %}secondary{% elif current_user.role == 'customer' %}success{% else %}info{% endif %} mt-1">
{{ current_user.role.replace('_',' ')|title }} {{ current_user.role.replace('_',' ')|title }}
</span> </span>
</div> </div>
@@ -58,7 +58,7 @@
<td class="small">{{ s.inspector.display_name if s.inspector else '—' }}</td> <td class="small">{{ s.inspector.display_name if s.inspector else '—' }}</td>
<td class="small">{{ s.next_due_date.strftime('%b %d') }}</td> <td class="small">{{ s.next_due_date.strftime('%b %d') }}</td>
<td class="text-end"> <td class="text-end">
{% if current_user.role in ['admin','director','project_manager'] {% if current_user.role in ['admin','director','project_manager','auditor']
or (current_user.role == 'inspector' and s.inspector_id == current_user.id) %} or (current_user.role == 'inspector' and s.inspector_id == current_user.id) %}
<a href="{{ url_for('scheduled_inspections.start', schedule_id=s.id) }}" <a href="{{ url_for('scheduled_inspections.start', schedule_id=s.id) }}"
class="btn btn-sm btn-success py-0"><i class="bi bi-play-fill"></i> Start</a> class="btn btn-sm btn-success py-0"><i class="bi bi-play-fill"></i> Start</a>
+1 -1
View File
@@ -44,7 +44,7 @@
{% endif %} {% endif %}
</button> </button>
<span class="badge bg-secondary ms-2">{{ group.facilities|length }}</span> <span class="badge bg-secondary ms-2">{{ group.facilities|length }}</span>
{% if group.project and current_user.role in ['admin', 'director', 'project_manager'] %} {% if group.project and current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
<a href="{{ url_for('projects.view', project_id=group.project.id) }}" <a href="{{ url_for('projects.view', project_id=group.project.id) }}"
class="btn btn-sm btn-outline-secondary ms-2" class="btn btn-sm btn-outline-secondary ms-2"
title="View Contract"> title="View Contract">
+3 -3
View File
@@ -11,13 +11,13 @@
<a href="{{ url_for('facilities.list_facilities') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('facilities.list_facilities') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Facilities <i class="bi bi-arrow-left"></i> Back to Facilities
</a> </a>
{% if current_user.role in ['admin', 'director', 'project_manager', 'customer'] %} {% if current_user.role in ['admin', 'director', 'project_manager', 'customer', 'auditor'] %}
<a href="{{ url_for('reports.facility_report', facility_id=facility.id) }}" <a href="{{ url_for('reports.facility_report', facility_id=facility.id) }}"
class="btn btn-outline-info"> class="btn btn-outline-info">
<i class="bi bi-graph-up-arrow"></i> Scorecard <i class="bi bi-graph-up-arrow"></i> Scorecard
</a> </a>
{% endif %} {% endif %}
{% if current_user.role in ['admin', 'director', 'project_manager', 'customer'] %} {% if current_user.role in ['admin', 'director', 'project_manager', 'customer', 'auditor'] %}
<a href="{{ url_for('facilities.facility_qr_page', facility_id=facility.id) }}" <a href="{{ url_for('facilities.facility_qr_page', facility_id=facility.id) }}"
class="btn btn-outline-dark" title="Printable QR code for this facility"> class="btn btn-outline-dark" title="Printable QR code for this facility">
<i class="bi bi-qr-code"></i> QR Code <i class="bi bi-qr-code"></i> QR Code
@@ -135,7 +135,7 @@
</td> </td>
<td>{{ area.inspections.count() }}</td> <td>{{ area.inspections.count() }}</td>
<td> <td>
{% if current_user.role in ['admin', 'director', 'project_manager', 'customer'] %} {% if current_user.role in ['admin', 'director', 'project_manager', 'customer', 'auditor'] %}
<a href="{{ url_for('facilities.area_qr_page', area_id=area.id) }}" <a href="{{ url_for('facilities.area_qr_page', area_id=area.id) }}"
class="btn btn-sm btn-outline-dark" title="Printable QR code for this area"> class="btn btn-sm btn-outline-dark" title="Printable QR code for this area">
<i class="bi bi-qr-code"></i> <i class="bi bi-qr-code"></i>
+4 -4
View File
@@ -3,7 +3,7 @@
{% 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-exclamation-triangle"></i> Issues</h2> <h2><i class="bi bi-exclamation-triangle"></i> Issues</h2>
{% if current_user.role in ['admin','director','customer'] %} {% if current_user.role in ['admin','director','customer','auditor'] %}
<a href="{{ url_for('issues.create') }}" class="btn btn-danger"> <a href="{{ url_for('issues.create') }}" class="btn btn-danger">
<i class="bi bi-plus-circle"></i> Log Issue <i class="bi bi-plus-circle"></i> Log Issue
</a> </a>
@@ -167,7 +167,7 @@
{% else %}<span class="text-muted"></span>{% endif %} {% else %}<span class="text-muted"></span>{% endif %}
</td> </td>
<td> <td>
{% if current_user.role in ['admin', 'director'] and issue.status != 'resolved' %} {% if current_user.role in ['admin', 'director', 'auditor'] and issue.status != 'resolved' %}
<div class="d-flex align-items-center gap-1 quick-assign-wrap" data-issue-id="{{ issue.id }}"> <div class="d-flex align-items-center gap-1 quick-assign-wrap" data-issue-id="{{ issue.id }}">
<select class="form-select form-select-sm quick-assign-select" style="min-width:110px;font-size:.78rem;"> <select class="form-select form-select-sm quick-assign-select" style="min-width:110px;font-size:.78rem;">
<option value="">— Unassigned —</option> <option value="">— Unassigned —</option>
@@ -208,7 +208,7 @@
<a href="{{ url_for('issues.view', issue_id=issue.id) }}" <a href="{{ url_for('issues.view', issue_id=issue.id) }}"
class="btn btn-sm btn-outline-secondary"> class="btn btn-sm btn-outline-secondary">
{% if current_user.role in ['admin','director'] or issue.assigned_to == current_user.id %} {% if current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %}
<i class="bi bi-pencil"></i> Edit <i class="bi bi-pencil"></i> Edit
{% else %} {% else %}
<i class="bi bi-eye"></i> View <i class="bi bi-eye"></i> View
@@ -290,7 +290,7 @@
}()); }());
</script> </script>
{% if current_user.role in ['admin', 'director'] %} {% if current_user.role in ['admin', 'director', 'auditor'] %}
<script> <script>
(function () { (function () {
'use strict'; 'use strict';
+5 -5
View File
@@ -18,7 +18,7 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% set can_edit = current_user.role in ['admin','director'] or issue.assigned_to == current_user.id %} {% set can_edit = current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %}
<div class="row"> <div class="row">
{# ══════════════════════════════════ LEFT COLUMN ══════════════════════════════════ #} {# ══════════════════════════════════ LEFT COLUMN ══════════════════════════════════ #}
@@ -179,7 +179,7 @@
<div class="alert alert-info py-2 mb-0"> <div class="alert alert-info py-2 mb-0">
<i class="bi bi-hourglass-split me-1"></i> <i class="bi bi-hourglass-split me-1"></i>
<strong>Awaiting director verification.</strong> <strong>Awaiting director verification.</strong>
{% if current_user.role in ['admin','director'] %} {% if current_user.role in ['admin','director','auditor'] %}
<form method="POST" action="{{ url_for('issues.verify', issue_id=issue.id) }}" class="mt-2"> <form method="POST" action="{{ url_for('issues.verify', issue_id=issue.id) }}" class="mt-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-2"> <div class="mb-2">
@@ -368,7 +368,7 @@
{{ form.status.label(class="form-label fw-semibold") }} {{ form.status.label(class="form-label fw-semibold") }}
{{ form.status(class="form-select") }} {{ form.status(class="form-select") }}
</div> </div>
{% if current_user.role in ['admin','director','project_manager'] %} {% if current_user.role in ['admin','director','project_manager','auditor'] %}
{# ── Who handles this issue ── #} {# ── Who handles this issue ── #}
<div class="mb-3"> <div class="mb-3">
{{ form.handler_type.label(class="form-label fw-semibold") }} {{ form.handler_type.label(class="form-label fw-semibold") }}
@@ -377,7 +377,7 @@
</div> </div>
{% endif %} {% endif %}
{% if current_user.role in ['admin','director'] %} {% if current_user.role in ['admin','director','auditor'] %}
<div class="mb-3" id="assigned_to_wrap"> <div class="mb-3" id="assigned_to_wrap">
<label class="form-label fw-semibold" id="assigned_to_label">Assign To</label> <label class="form-label fw-semibold" id="assigned_to_label">Assign To</label>
{{ form.assigned_to(class="form-select") }} {{ form.assigned_to(class="form-select") }}
@@ -387,7 +387,7 @@
</div> </div>
{% endif %} {% endif %}
{% if current_user.role in ['admin','director','project_manager'] %} {% if current_user.role in ['admin','director','project_manager','auditor'] %}
{# ── Facility-staff handler (shown when Handled By = Facility Staff) ── #} {# ── Facility-staff handler (shown when Handled By = Facility Staff) ── #}
<div id="facility_handler_block" style="display:none;"> <div id="facility_handler_block" style="display:none;">
<hr class="my-3"> <hr class="my-3">
+2 -2
View File
@@ -17,7 +17,7 @@
<i class="bi bi-shield-check me-1"></i>SLA Compliance <i class="bi bi-shield-check me-1"></i>SLA Compliance
</a> </a>
</li> </li>
{% if current_user.role in ['admin', 'director', 'project_manager'] %} {% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'reports.followup_closure' else '' }}" <a class="nav-link {{ 'active' if request.endpoint == 'reports.followup_closure' else '' }}"
href="{{ url_for('reports.followup_closure') }}"> href="{{ url_for('reports.followup_closure') }}">
@@ -33,7 +33,7 @@
</a> </a>
</li> </li>
{% endif %} {% endif %}
{% if current_user.role in ['admin', 'director', 'project_manager'] %} {% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('scheduled_reports.') else '' }}" <a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('scheduled_reports.') else '' }}"
href="{{ url_for('scheduled_reports.index') }}"> href="{{ url_for('scheduled_reports.index') }}">
@@ -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'] %} {% if current_user.role in ['admin','director','project_manager','auditor'] %}
<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>
@@ -60,14 +60,14 @@
{% endif %} {% endif %}
</td> </td>
<td class="text-end text-nowrap"> <td class="text-end text-nowrap">
{% if s.active and (current_user.role in ['admin','director','project_manager'] {% if s.active and (current_user.role in ['admin','director','project_manager','auditor']
or (current_user.role == 'inspector' and s.inspector_id == current_user.id)) %} or (current_user.role == 'inspector' and s.inspector_id == current_user.id)) %}
<a href="{{ url_for('scheduled_inspections.start', schedule_id=s.id) }}" <a href="{{ url_for('scheduled_inspections.start', schedule_id=s.id) }}"
class="btn btn-sm btn-success" title="Start this inspection"> class="btn btn-sm btn-success" title="Start this inspection">
<i class="bi bi-play-fill"></i> Start <i class="bi bi-play-fill"></i> Start
</a> </a>
{% endif %} {% endif %}
{% if current_user.role in ['admin','director','project_manager'] %} {% if current_user.role in ['admin','director','project_manager','auditor'] %}
<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"
@@ -86,7 +86,7 @@
{% else %} {% else %}
<div class="p-4 text-muted text-center"> <div class="p-4 text-muted text-center">
No scheduled inspections yet. No scheduled inspections yet.
{% if current_user.role in ['admin','director','project_manager'] %} {% if current_user.role in ['admin','director','project_manager','auditor'] %}
<a href="{{ url_for('scheduled_inspections.create') }}">Create one</a>. <a href="{{ url_for('scheduled_inspections.create') }}">Create one</a>.
{% endif %} {% endif %}
</div> </div>
+24 -2
View File
@@ -54,17 +54,39 @@ def supervisor_required(f):
return decorated_function return decorated_function
def project_manager_required(f): def project_manager_required(f):
"""Grants access to admin, director, and project_manager roles.""" """Grants access to admin, director, project_manager, and auditor roles.
Auditor mirrors Project Manager for all baseline access, so it is included
here alongside project_manager.
"""
@wraps(f) @wraps(f)
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):
if not current_user.is_authenticated or current_user.role not in [ if not current_user.is_authenticated or current_user.role not in [
'admin', 'director', 'project_manager' 'admin', 'director', 'project_manager', 'auditor'
]: ]:
flash('Project Manager access required.', 'danger') flash('Project Manager access required.', 'danger')
return redirect(url_for('dashboard.index')) return redirect(url_for('dashboard.index'))
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated_function return decorated_function
def issue_manager_required(f):
"""Grants access to admin, director, and auditor roles.
Used for issue-management powers that go beyond the Project Manager
baseline (verification and the verification queue). Deliberately does NOT
include project_manager, and does NOT grant issue deletion delete stays
on @supervisor_required (admin/director only).
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated or current_user.role not in [
'admin', 'director', 'auditor'
]:
flash('Issue management access required.', 'danger')
return redirect(url_for('dashboard.index'))
return f(*args, **kwargs)
return decorated_function
def customer_required(f): def customer_required(f):
"""Restricts access to customer-role users only. """Restricts access to customer-role users only.
+1
View File
@@ -55,6 +55,7 @@ class UserForm(FlaskForm):
('director', 'Director'), ('director', 'Director'),
('inspector', 'Inspector'), ('inspector', 'Inspector'),
('project_manager', 'Project Manager'), ('project_manager', 'Project Manager'),
('auditor', 'Auditor'),
# 'customer' is intentionally excluded — customer accounts are managed via /customers # 'customer' is intentionally excluded — customer accounts are managed via /customers
], validators=[Optional()]) ], validators=[Optional()])
# NOTE: Optional() here because directors submit no role value (the field is # NOTE: Optional() here because directors submit no role value (the field is
+1
View File
@@ -546,6 +546,7 @@ def notify_by_matrix(
'director': 'director', 'director': 'director',
'inspector': 'inspector', 'inspector': 'inspector',
'project_manager': 'project_manager', 'project_manager': 'project_manager',
'auditor': 'auditor',
'customer': 'customer', 'customer': 'customer',
} }
@@ -0,0 +1,45 @@
"""phase40 — add 'auditor' role to users.role ENUM
Introduces a new staff role, Auditor, with the same access as Project Manager
plus full issue-management powers (assign, verify, quick-assign, handler triage,
create) but NOT issue deletion (that stays admin/director).
This is a pure ENUM expansion (adds a value, no data migration, no value
removal), so the 3-step ENUM protocol does not apply. Re-running the same
MODIFY is a no-op safe to re-run.
"""
revision = 'phase40_auditor_role'
down_revision = 'phase39_area_public_token'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
_ENUM_WITH_AUDITOR = (
"ENUM('admin','director','inspector','project_manager','customer','auditor')"
)
_ENUM_WITHOUT_AUDITOR = (
"ENUM('admin','director','inspector','project_manager','customer')"
)
def upgrade():
bind = op.get_bind()
# Idempotent: MODIFY to the expanded set is harmless if already applied.
op.execute(sa.text(
f"ALTER TABLE users MODIFY COLUMN role {_ENUM_WITH_AUDITOR} NOT NULL"
))
def downgrade():
bind = op.get_bind()
# Reassign any auditor rows before contracting the ENUM so no data is lost.
op.execute(sa.text(
"UPDATE users SET role = 'project_manager' WHERE role = 'auditor'"
))
op.execute(sa.text(
f"ALTER TABLE users MODIFY COLUMN role {_ENUM_WITHOUT_AUDITOR} NOT NULL"
))