04/09 update: redesign user's roles

This commit is contained in:
2026-04-09 17:36:32 -04:00
parent 857372f781
commit dba4cc8b96
29 changed files with 194 additions and 114 deletions
+2 -2
View File
@@ -99,9 +99,9 @@ def create_app(config_name='default'):
unread = Notification.query.filter_by(
user_id=current_user.id, is_read=False
).count()
# Pending verification count — only computed for supervisor+ roles
# Pending verification count — only computed for director+ roles
pv_count = 0
if current_user.role in ('admin', 'supervisor'):
if current_user.role in ('admin', 'director'):
pv_count = Issue.query.filter_by(
status='pending_verification'
).count()
+24 -24
View File
@@ -8,7 +8,7 @@ One row per (event_type, role_key) pair.
role_key values
---------------
admin — all users with role='admin'
supervisor — all users with role='supervisor'
director — all users with role='director'
inspector — all users with role='inspector'
project_manager — all users with role='project_manager'
customer — all customer-portal users assigned to the relevant facility
@@ -18,16 +18,16 @@ custom — free-form extra email addresses stored in custom_emails JSO
Default matrix (mirrors current hardcoded behaviour)
-----------------------------------------------------
inspection_completed : admin ✓ supervisor ✓ inspector ✗ pm ✗ customer ✓
issue_assigned : admin ✗ supervisor ✗ inspector ✗ pm ✗ customer ✗ (assignee implicit)
issue_status : admin ✗ supervisor ✗ inspector ✗ pm ✗ customer ✗ (assignee implicit)
issue_comment : admin ✗ supervisor ✗ inspector ✗ pm ✗ customer ✗ (assignee implicit)
issue_follow_update : admin ✗ supervisor ✗ inspector ✗ pm ✗ customer ✗ (followers implicit)
issue_flagged : admin ✗ supervisor ✗ inspector ✗ pm ✗ customer ✓ (assignee implicit)
issue_created : admin ✗ supervisor ✗ inspector ✗ pm ✗ customer ✓ (assignee implicit)
issue_updated_customer : admin ✗ supervisor ✗ inspector ✗ pm ✗ customer ✓
verification_requested : admin ✓ supervisor ✓ inspector ✗ pm ✗ customer ✗
sla_alert : admin ✓ supervisor ✗ inspector ✗ pm ✗ customer ✗ (assignee + followers implicit)
inspection_completed : admin ✓ director ✓ inspector ✗ pm ✗ customer ✓
issue_assigned : admin ✗ director ✗ inspector ✗ pm ✗ customer ✗ (assignee implicit)
issue_status : admin ✗ director ✗ inspector ✗ pm ✗ customer ✗ (assignee implicit)
issue_comment : admin ✗ director ✗ inspector ✗ pm ✗ customer ✗ (assignee implicit)
issue_follow_update : admin ✗ director ✗ inspector ✗ pm ✗ customer ✗ (followers implicit)
issue_flagged : admin ✗ director ✗ inspector ✗ pm ✗ customer ✓ (assignee implicit)
issue_created : admin ✗ director ✗ inspector ✗ pm ✗ customer ✓ (assignee implicit)
issue_updated_customer : admin ✗ director ✗ inspector ✗ pm ✗ customer ✓
verification_requested : admin ✓ director ✓ inspector ✗ pm ✗ customer ✗
sla_alert : admin ✓ director ✗ inspector ✗ pm ✗ customer ✗ (assignee + followers implicit)
"""
import json
@@ -36,7 +36,7 @@ from app import db
# Role keys available in the matrix UI
MATRIX_ROLES = [
('admin', 'Admin'),
('supervisor', 'Supervisor'),
('director', 'Director'),
('inspector', 'Inspector'),
('project_manager', 'Project Manager'),
('customer', 'Customer'),
@@ -65,84 +65,84 @@ MATRIX_EVENTS = {
MATRIX_DEFAULTS = {
# inspection_completed
('inspection_completed', 'admin'): True,
('inspection_completed', 'supervisor'): True,
('inspection_completed', 'director'): True,
('inspection_completed', 'inspector'): False,
('inspection_completed', 'project_manager'): False,
('inspection_completed', 'customer'): True,
('inspection_completed', 'custom'): False,
# issue_assigned (assignee is always notified implicitly)
('issue_assigned', 'admin'): False,
('issue_assigned', 'supervisor'): False,
('issue_assigned', 'director'): False,
('issue_assigned', 'inspector'): False,
('issue_assigned', 'project_manager'): False,
('issue_assigned', 'customer'): False,
('issue_assigned', 'custom'): False,
# issue_reassigned
('issue_reassigned', 'admin'): False,
('issue_reassigned', 'supervisor'): False,
('issue_reassigned', 'director'): False,
('issue_reassigned', 'inspector'): False,
('issue_reassigned', 'project_manager'): False,
('issue_reassigned', 'customer'): False,
('issue_reassigned', 'custom'): False,
# issue_unassigned
('issue_unassigned', 'admin'): False,
('issue_unassigned', 'supervisor'): False,
('issue_unassigned', 'director'): False,
('issue_unassigned', 'inspector'): False,
('issue_unassigned', 'project_manager'): False,
('issue_unassigned', 'customer'): False,
('issue_unassigned', 'custom'): False,
# issue_status
('issue_status', 'admin'): False,
('issue_status', 'supervisor'): False,
('issue_status', 'director'): False,
('issue_status', 'inspector'): False,
('issue_status', 'project_manager'): False,
('issue_status', 'customer'): False,
('issue_status', 'custom'): False,
# issue_comment
('issue_comment', 'admin'): False,
('issue_comment', 'supervisor'): False,
('issue_comment', 'director'): False,
('issue_comment', 'inspector'): False,
('issue_comment', 'project_manager'): False,
('issue_comment', 'customer'): False,
('issue_comment', 'custom'): False,
# issue_follow_update (followers always notified implicitly)
('issue_follow_update', 'admin'): False,
('issue_follow_update', 'supervisor'): False,
('issue_follow_update', 'director'): False,
('issue_follow_update', 'inspector'): False,
('issue_follow_update', 'project_manager'): False,
('issue_follow_update', 'customer'): False,
('issue_follow_update', 'custom'): False,
# issue_flagged (from inspection)
('issue_flagged', 'admin'): False,
('issue_flagged', 'supervisor'): False,
('issue_flagged', 'director'): False,
('issue_flagged', 'inspector'): False,
('issue_flagged', 'project_manager'): False,
('issue_flagged', 'customer'): True,
('issue_flagged', 'custom'): False,
# issue_created (standalone)
('issue_created', 'admin'): False,
('issue_created', 'supervisor'): False,
('issue_created', 'director'): False,
('issue_created', 'inspector'): False,
('issue_created', 'project_manager'): False,
('issue_created', 'customer'): True,
('issue_created', 'custom'): False,
# issue_updated_customer
('issue_updated_customer', 'admin'): False,
('issue_updated_customer', 'supervisor'): False,
('issue_updated_customer', 'director'): False,
('issue_updated_customer', 'inspector'): False,
('issue_updated_customer', 'project_manager'): False,
('issue_updated_customer', 'customer'): True,
('issue_updated_customer', 'custom'): False,
# verification_requested
('verification_requested', 'admin'): True,
('verification_requested', 'supervisor'): True,
('verification_requested', 'director'): True,
('verification_requested', 'inspector'): False,
('verification_requested', 'project_manager'): False,
('verification_requested', 'customer'): False,
('verification_requested', 'custom'): False,
# sla_alert (assignee + followers always notified implicitly)
('sla_alert', 'admin'): True,
('sla_alert', 'supervisor'): False,
('sla_alert', 'director'): False,
('sla_alert', 'inspector'): False,
('sla_alert', 'project_manager'): False,
('sla_alert', 'customer'): False,
+4 -1
View File
@@ -16,7 +16,10 @@ class User(UserMixin, db.Model):
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
role = db.Column(
db.Enum('admin', 'supervisor', 'inspector', 'project_manager', 'customer'),
# 'supervisor' retained temporarily so the Enum is valid before the
# migration UPDATE runs. The migration removes it after all rows are
# updated to 'director'.
db.Enum('admin', 'supervisor', 'director', 'inspector', 'project_manager', 'customer'),
nullable=False
)
created_at = db.Column(db.DateTime, default=now_eastern)
+6 -6
View File
@@ -4,7 +4,7 @@ from urllib.parse import urlparse
from app import db
from app.models.user import User
from app.utils.forms import LoginForm, UserForm, ProfileForm
from app.utils.decorators import admin_required
from app.utils.decorators import admin_required, supervisor_required
import logging
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT
@@ -127,7 +127,7 @@ def profile():
@bp.route('/users')
@login_required
@admin_required
@supervisor_required
def list_users():
# Exclude customer accounts — those are managed exclusively via /customers
users = (
@@ -143,7 +143,7 @@ def list_users():
@bp.route('/users/new', methods=['GET', 'POST'])
@login_required
@admin_required
@supervisor_required
def create_user():
form = UserForm()
@@ -169,7 +169,7 @@ def create_user():
@bp.route('/users/<int:user_id>/edit', methods=['GET', 'POST'])
@login_required
@admin_required
@supervisor_required
def edit_user(user_id):
user = User.query.get_or_404(user_id)
form = UserForm(user=user, obj=user)
@@ -196,7 +196,7 @@ def edit_user(user_id):
@bp.route('/users/<int:user_id>/delete', methods=['POST'])
@login_required
@admin_required
@supervisor_required
def delete_user(user_id):
user = User.query.get_or_404(user_id)
@@ -227,7 +227,7 @@ def delete_user(user_id):
@bp.route('/users/<int:user_id>/toggle-active', methods=['POST'])
@login_required
@admin_required
@supervisor_required
def toggle_active(user_id):
user = User.query.get_or_404(user_id)
+12 -12
View File
@@ -20,7 +20,7 @@ from app.models.user import User
from app.models.project import Project, CustomerAssignment
from app.models.facility import Facility
from app.utils.forms import CustomerUserForm, CustomerAssignmentForm, CustomerInviteForm, SetPasswordForm
from app.utils.decorators import admin_required
from app.utils.decorators import admin_required, supervisor_required
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
from app.utils.scope import get_customer_scope
@@ -33,7 +33,7 @@ bp = Blueprint('customers', __name__, url_prefix='/customers')
@bp.route('/')
@login_required
@admin_required
@supervisor_required
def index():
"""Consolidated customer management dashboard."""
customers = (
@@ -101,7 +101,7 @@ def index():
@bp.route('/new', methods=['GET', 'POST'])
@login_required
@admin_required
@supervisor_required
def create():
"""Create a customer account via email invitation.
@@ -235,7 +235,7 @@ def _send_invite_email(user, token):
@bp.route('/<int:customer_id>/resend-invite', methods=['POST'])
@login_required
@admin_required
@supervisor_required
def resend_invite(customer_id):
"""Generate a fresh token and resend the set-password invitation email."""
customer = User.query.get_or_404(customer_id)
@@ -295,7 +295,7 @@ def set_password(token):
@bp.route('/<int:customer_id>/edit', methods=['GET', 'POST'])
@login_required
@admin_required
@supervisor_required
def edit(customer_id):
customer = User.query.get_or_404(customer_id)
if customer.role != 'customer':
@@ -326,7 +326,7 @@ def edit(customer_id):
@bp.route('/<int:customer_id>')
@login_required
@admin_required
@supervisor_required
def manage(customer_id):
"""Single-customer detail page: profile + all assignments."""
customer = User.query.get_or_404(customer_id)
@@ -363,7 +363,7 @@ def manage(customer_id):
@bp.route('/<int:customer_id>/assignments/add', methods=['POST'])
@login_required
@admin_required
@supervisor_required
def add_assignment(customer_id):
customer = User.query.get_or_404(customer_id)
if customer.role != 'customer':
@@ -412,7 +412,7 @@ def add_assignment(customer_id):
@bp.route('/assignments/<int:assignment_id>/remove', methods=['POST'])
@login_required
@admin_required
@supervisor_required
def remove_assignment(assignment_id):
assignment = CustomerAssignment.query.get_or_404(assignment_id)
customer_id = assignment.user_id
@@ -437,7 +437,7 @@ def remove_assignment(assignment_id):
@bp.route('/<int:customer_id>/toggle-active', methods=['POST'])
@login_required
@admin_required
@supervisor_required
def toggle_active(customer_id):
customer = User.query.get_or_404(customer_id)
if customer.role != 'customer':
@@ -463,7 +463,7 @@ def toggle_active(customer_id):
@bp.route('/import/template')
@login_required
@admin_required
@supervisor_required
def import_template():
"""Download a blank CSV template showing the expected import format."""
import csv, io
@@ -495,7 +495,7 @@ def import_template():
@bp.route('/import', methods=['GET', 'POST'])
@login_required
@admin_required
@supervisor_required
def bulk_import():
"""Two-phase CSV import for customer accounts.
@@ -744,7 +744,7 @@ def bulk_import():
@bp.route('/facilities-for-project/<int:project_id>')
@login_required
@admin_required
@supervisor_required
def facilities_for_project(project_id):
from flask import jsonify
project = Project.query.get_or_404(project_id)
+2 -2
View File
@@ -24,7 +24,7 @@ def index():
thirty_days_ago = now - timedelta(days=30)
is_inspector = current_user.role == 'inspector'
is_privileged = current_user.role in ['admin', 'supervisor']
is_privileged = current_user.role in ['admin', 'director']
is_customer = current_user.role == 'customer'
is_project_manager = current_user.role == 'project_manager'
@@ -107,7 +107,7 @@ def index():
followup_q = followup_q.filter(False)
pending_followups = followup_q.count()
# ── System stats (admin/supervisor) ───────────────────────────────────
# ── System stats (admin/director) ────────────────────────────────────────
total_facilities = Facility.query.filter_by(active=True).count() if is_privileged else 0
total_templates = InspectionTemplate.query.count() if is_privileged else 0
total_users = User.query.count() if current_user.role == 'admin' else 0
+1 -1
View File
@@ -673,7 +673,7 @@ def flag_issue(inspection_id):
form = IssueForm()
areas = Area.query.filter_by(facility_id=inspection.facility_id).order_by(Area.name).all()
staff = User.query.filter(User.role.in_(['supervisor', 'inspector'])).order_by(User.username).all()
staff = User.query.filter(User.role.in_(['director', 'inspector'])).order_by(User.username).all()
form.area_id.choices = [(a.id, a.name) for a in areas]
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.username) for u in staff]
+7 -7
View File
@@ -114,7 +114,7 @@ def view(issue_id):
return redirect(url_for('issues.index'))
form = IssueUpdateForm(obj=issue)
staff = User.query.filter(User.role.in_(['supervisor','inspector'])).order_by(User.username).all()
staff = User.query.filter(User.role.in_(['director','inspector'])).order_by(User.username).all()
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.username) for u in staff]
form.status.data = form.status.data or issue.status
@@ -124,7 +124,7 @@ def view(issue_id):
issue.status = form.status.data
if current_user.role in ['admin', 'supervisor']:
if current_user.role in ['admin', 'director']:
issue.assigned_to = form.assigned_to.data or None
if form.status.data == 'resolved' and not issue.resolved_at:
@@ -353,7 +353,7 @@ def unfollow(issue_id):
def create():
form = IssueForm()
areas = Area.query.join(Facility).filter(Facility.active == True).order_by(Facility.name, Area.name).all()
staff = User.query.filter(User.role.in_(['supervisor','inspector'])).order_by(User.username).all()
staff = User.query.filter(User.role.in_(['director','inspector'])).order_by(User.username).all()
form.area_id.choices = [(a.id, f"{a.facility.name}{a.name}") for a in areas]
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.username) for u in staff]
@@ -460,16 +460,16 @@ def verify(issue_id):
@bp.route('/<int:issue_id>/request-verification', methods=['POST'])
@login_required
def request_verification(issue_id):
"""Inspector/assignee marks the issue as pending supervisor verification."""
"""Inspector/assignee marks the issue as pending director verification."""
issue = Issue.query.get_or_404(issue_id)
if current_user.role == 'customer':
flash('Access denied.', 'danger')
return redirect(url_for('issues.index'))
# Only the assignee, supervisor, or admin can request verification
# Only the assignee, director, or admin can request verification
can_act = (
current_user.role in ['admin', 'supervisor']
current_user.role in ['admin', 'director']
or issue.assigned_to == current_user.id
)
if not can_act:
@@ -556,7 +556,7 @@ def verification_queue():
def delete(issue_id):
"""Permanently delete an issue and its associated photos.
Restricted to admin and supervisor roles. The deletion is recorded in
Restricted to admin and director roles. The deletion is recorded in
the audit log before the record is removed so there is always a trace.
"""
issue = Issue.query.get_or_404(issue_id)
+4 -4
View File
@@ -38,8 +38,8 @@ def _date_range():
@bp.route('/')
@login_required
def index():
# Customers get a scoped view; internal staff need supervisor+ access
if current_user.role not in ['admin', 'supervisor', 'project_manager', 'customer']:
# Customers get a scoped view; internal staff need director+ access
if current_user.role not in ['admin', 'director', 'project_manager', 'customer']:
from flask import flash, redirect, url_for
flash('Access denied.', 'danger')
return redirect(url_for('dashboard.index'))
@@ -179,7 +179,7 @@ def index():
@bp.route('/facility/<int:facility_id>')
@login_required
def facility_report(facility_id):
if current_user.role not in ['admin', 'supervisor', 'project_manager', 'customer']:
if current_user.role not in ['admin', 'director', 'project_manager', 'customer']:
from flask import flash, redirect, url_for
flash('Access denied.', 'danger')
return redirect(url_for('dashboard.index'))
@@ -228,7 +228,7 @@ def facility_report(facility_id):
def facility_scorecard(facility_id):
"""Comprehensive per-facility scorecard: score trend, SLA compliance,
issue breakdown by severity, inspection frequency."""
if current_user.role not in ['admin', 'supervisor', 'project_manager', 'customer']:
if current_user.role not in ['admin', 'director', 'project_manager', 'customer']:
from flask import flash, redirect, url_for
flash('Access denied.', 'danger')
return redirect(url_for('dashboard.index'))
+1 -1
View File
@@ -109,7 +109,7 @@
<strong>{{ entry.username }}</strong>
</td>
<td>
<span class="badge bg-{% if entry.user_role == 'admin' %}danger{% elif entry.user_role == 'supervisor' %}warning{% else %}info{% endif %} bg-opacity-75">
<span class="badge bg-{% if entry.user_role == 'admin' %}danger{% elif entry.user_role == 'director' %}warning{% else %}info{% endif %} bg-opacity-75">
{{ entry.user_role | title }}
</span>
</td>
+1 -1
View File
@@ -86,7 +86,7 @@
<dt class="col-sm-4 text-muted">Role at Time</dt>
<dd class="col-sm-8">
<span class="badge bg-{% if entry.user_role == 'admin' %}danger{% elif entry.user_role == 'supervisor' %}warning{% else %}info{% endif %}">
<span class="badge bg-{% if entry.user_role == 'admin' %}danger{% elif entry.user_role == 'director' %}warning{% else %}info{% endif %}">
{{ entry.user_role | title }}
</span>
</dd>
+2 -2
View File
@@ -28,8 +28,8 @@
<p class="text-muted mb-1" style="font-size:.85rem;">@{{ current_user.username }}</p>
{% endif %}
<p class="text-muted mb-2">{{ current_user.email }}</p>
<span class="badge fs-6 bg-{% if current_user.role == 'admin' %}danger{% elif current_user.role == 'supervisor' %}warning{% else %}info{% endif %}">
<i class="bi bi-{% if current_user.role == 'admin' %}shield-fill{% elif current_user.role == 'supervisor' %}star-fill{% else %}person-badge-fill{% endif %} me-1"></i>
<span class="badge fs-6 bg-{% if current_user.role == 'admin' %}danger{% elif current_user.role == 'director' %}warning{% else %}info{% endif %}">
<i class="bi bi-{% if current_user.role == 'admin' %}shield-fill{% elif current_user.role == 'director' %}star-fill{% else %}person-badge-fill{% endif %} me-1"></i>
{{ current_user.role | title }}
</span>
<hr>
+4 -5
View File
@@ -3,12 +3,11 @@
{% block title %}User Management{% endblock %}
{% block content %}
<div class="row mb-4 align-items-center">
<div class="col">
<div class="row mb-4">
<div class="col-md-6">
<h2><i class="bi bi-people-fill"></i> Internal User Management</h2>
<p class="text-muted mb-0">Manage staff accounts (Admin, Supervisor, Inspector, Project Manager). For customer accounts, visit <a href="{{ url_for('customers.index') }}">Customer Management</a>.</p>
</div>
<div class="col-auto">
<div class="col-md-6 text-end">
<a href="{{ url_for('auth.create_user') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add User
</a>
@@ -37,7 +36,7 @@
<td>{{ user.full_name or '—' }}</td>
<td>{{ user.email }}</td>
<td>
<span class="badge bg-{% if user.role == 'admin' %}danger{% elif user.role == 'supervisor' %}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 == 'customer' %}success{% else %}info{% endif %}">
{{ user.role.replace('_',' ')|title }}
</span>
</td>
+7 -5
View File
@@ -101,7 +101,7 @@
<li class="nav-item">
<a class="nav-link" href="{{ url_for('dashboard.index') }}">Dashboard</a>
</li>
{% if current_user.role in ['admin', 'supervisor', 'project_manager'] %}
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('projects.index') }}">Projects</a>
</li>
@@ -109,7 +109,7 @@
<li class="nav-item">
<a class="nav-link" href="{{ url_for('facilities.list_facilities') }}">Facilities</a>
</li>
{% if current_user.role != 'customer' %}
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('templates.index') }}">Templates</a>
</li>
@@ -120,7 +120,7 @@
<li class="nav-item">
<a class="nav-link" href="{{ url_for('reports.index') }}">Reports</a>
</li>
{% if current_user.role in ['admin', 'supervisor'] %}
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('scheduled_reports.index') }}">Scheduled Reports</a>
</li>
@@ -128,7 +128,7 @@
<li class="nav-item">
<a class="nav-link" href="{{ url_for('issues.index') }}">Issues</a>
</li>
{% if current_user.role in ['admin', 'supervisor'] %}
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link d-flex align-items-center gap-1"
href="{{ url_for('issues.verification_queue') }}">
@@ -142,13 +142,15 @@
</a>
</li>
{% endif %}
{% if current_user.role == 'admin' %}
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.list_users') }}">Users</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('customers.index') }}">Customers</a>
</li>
{% endif %}
{% if current_user.role == 'admin' %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('audit.index') }}">Audit Trail</a>
</li>
+5 -5
View File
@@ -5,7 +5,7 @@
<div class="row mb-3 align-items-center">
<div class="col">
<h2 class="mb-0">Welcome, {{ current_user.username }}!</h2>
<span class="badge bg-{% if current_user.role == 'admin' %}danger{% elif current_user.role == 'supervisor' %}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 == 'customer' %}success{% else %}info{% endif %} mt-1">
{{ current_user.role.replace('_',' ')|title }}
</span>
</div>
@@ -171,8 +171,8 @@
</div>
{% endif %}
{# ── System quick stats (admin/supervisor) ──────────────────────────────── #}
{% if current_user.role in ['admin', 'supervisor'] %}
{# ── System quick stats (admin/director) ─────────────────────────────────── #}
{% if current_user.role in ['admin', 'director'] %}
<div class="row g-3 mb-4">
<div class="col-6 col-md-{% if current_user.role == 'admin' %}4{% else %}6{% endif %}">
<div class="card shadow-sm text-center">
@@ -208,7 +208,7 @@
{# ── Score trend chart + Facility performance ───────────────────────────── #}
<div class="row g-3 mb-4">
<div class="col-lg-{% if current_user.role in ['admin','supervisor'] and facility_perf %}7{% else %}12{% endif %}">
<div class="col-lg-{% if current_user.role in ['admin','director'] and facility_perf %}7{% else %}12{% endif %}">
<div class="card shadow-sm h-100">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-graph-up me-1"></i>Inspection Score Trend (Last 30 Days)
@@ -226,7 +226,7 @@
</div>
</div>
{% if current_user.role in ['admin', 'supervisor'] and facility_perf %}
{% if current_user.role in ['admin', 'director'] and facility_perf %}
<div class="col-lg-5">
<div class="card shadow-sm h-100">
<div class="card-header bg-light fw-semibold">
+1 -1
View File
@@ -8,7 +8,7 @@
<h2><i class="bi bi-building"></i> Facilities</h2>
</div>
<div class="col-md-6 text-end">
{% if current_user.role in ['admin', 'supervisor'] %}
{% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('facilities.create_facility') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add Facility
</a>
+3 -3
View File
@@ -8,13 +8,13 @@
<h2><i class="bi bi-building"></i> {{ facility.name }}</h2>
</div>
<div class="col-md-4 text-end d-flex gap-2 justify-content-end align-items-start">
{% if current_user.role in ['admin', 'supervisor', 'project_manager', 'customer'] %}
{% if current_user.role in ['admin', 'director', 'project_manager', 'customer'] %}
<a href="{{ url_for('reports.facility_report', facility_id=facility.id) }}"
class="btn btn-outline-info">
<i class="bi bi-graph-up-arrow"></i> Scorecard
</a>
{% endif %}
{% if current_user.role in ['admin', 'supervisor'] %}
{% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('facilities.edit_facility', facility_id=facility.id) }}" class="btn btn-outline-primary">
<i class="bi bi-pencil"></i> Edit
</a>
@@ -126,7 +126,7 @@
</td>
<td>{{ area.inspections.count() }}</td>
<td>
{% if current_user.role in ['admin', 'supervisor'] %}
{% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('facilities.edit_area', area_id=area.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i>
</a>
+3 -3
View File
@@ -83,7 +83,7 @@
{% else %}
<a href="{{ url_for('inspections.view', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-secondary">View</a>
{% endif %}
{% if current_user.role in ['admin', 'supervisor'] %}
{% if current_user.role in ['admin', 'director'] %}
<button type="button"
class="btn btn-sm btn-outline-danger ms-1"
data-bs-toggle="modal"
@@ -121,7 +121,7 @@
{% endif %}
</div>
</div>
{% if current_user.role in ['admin', 'supervisor'] %}
{% if current_user.role in ['admin', 'director'] %}
<!-- Delete Inspection Confirmation Modal -->
<div class="modal fade" id="deleteInspectionModal" tabindex="-1" aria-labelledby="deleteInspectionModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
@@ -155,7 +155,7 @@
{% endblock %}
{% block extra_js %}
{% if current_user.role in ['admin', 'supervisor'] %}
{% if current_user.role in ['admin', 'director'] %}
<script>
document.addEventListener('DOMContentLoaded', function () {
const modal = document.getElementById('deleteInspectionModal');
+1 -1
View File
@@ -356,7 +356,7 @@
<i class="bi bi-arrow-repeat"></i> Re-inspect
</a>
{% endif %}
{% if current_user.role in ['admin','supervisor'] %}
{% if current_user.role in ['admin','director'] %}
{% if not inspection.follow_up_required %}
<button type="button" class="btn btn-sm btn-outline-warning"
data-bs-toggle="modal" data-bs-target="#followupModal"
+3 -3
View File
@@ -3,7 +3,7 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-exclamation-triangle"></i> Issues</h2>
{% if current_user.role in ['admin','supervisor'] %}
{% if current_user.role in ['admin','director'] %}
<a href="{{ url_for('issues.create') }}" class="btn btn-danger">
<i class="bi bi-plus-circle"></i> Log Issue
</a>
@@ -100,13 +100,13 @@
<a href="{{ url_for('issues.view', issue_id=issue.id) }}"
class="btn btn-sm btn-outline-secondary">
{% if current_user.role in ['admin','supervisor'] or issue.assigned_to == current_user.id %}
{% if current_user.role in ['admin','director'] or issue.assigned_to == current_user.id %}
<i class="bi bi-pencil"></i> Edit
{% else %}
<i class="bi bi-eye"></i> View
{% endif %}
</a>
{% if current_user.role in ['admin', 'supervisor'] %}
{% if current_user.role in ['admin', 'director'] %}
<form method="POST" action="{{ url_for('issues.delete', issue_id=issue.id) }}"
class="d-inline"
onsubmit="return confirm('Permanently delete Issue #{{ issue.id }}? This cannot be undone.');">
+7 -7
View File
@@ -83,8 +83,8 @@
<hr>
<div class="alert alert-info py-2 mb-0">
<i class="bi bi-hourglass-split me-1"></i>
<strong>Awaiting supervisor verification.</strong>
{% if current_user.role in ['admin','supervisor'] %}
<strong>Awaiting director verification.</strong>
{% if current_user.role in ['admin','director'] %}
<form method="POST" action="{{ url_for('issues.verify', issue_id=issue.id) }}" class="mt-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-2">
@@ -163,7 +163,7 @@
</div>
{# ── Update Form ────────────────────────────────────────────────────── #}
{% set can_edit = current_user.role in ['admin','supervisor'] or issue.assigned_to == current_user.id %}
{% set can_edit = current_user.role in ['admin','director'] or issue.assigned_to == current_user.id %}
{% if can_edit %}
<div class="card shadow-sm">
<div class="card-header bg-light"><h6 class="mb-0">Update Issue</h6></div>
@@ -174,7 +174,7 @@
{{ form.status.label(class="form-label fw-semibold") }}
{{ form.status(class="form-select") }}
</div>
{% if current_user.role in ['admin','supervisor'] %}
{% if current_user.role in ['admin','director'] %}
<div class="mb-3">
{{ form.assigned_to.label(class="form-label fw-semibold") }}
{{ form.assigned_to(class="form-select") }}
@@ -209,7 +209,7 @@
class="mt-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-outline-info w-100"
onclick="return confirm('Mark this issue as pending supervisor verification?')">
onclick="return confirm('Mark this issue as pending director verification?')">
<i class="bi bi-hourglass-split me-1"></i> Request Verification
</button>
</form>
@@ -224,7 +224,7 @@
<a href="{{ url_for('issues.index') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Back to Issues
</a>
{% if current_user.role in ['admin', 'supervisor'] %}
{% if current_user.role in ['admin', 'director'] %}
<button type="button" class="btn btn-outline-danger btn-sm"
data-bs-toggle="modal" data-bs-target="#deleteIssueModal">
<i class="bi bi-trash"></i> Delete Issue
@@ -232,7 +232,7 @@
{% endif %}
</div>
{% if current_user.role in ['admin', 'supervisor'] %}
{% if current_user.role in ['admin', 'director'] %}
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteIssueModal" tabindex="-1" aria-labelledby="deleteIssueModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
+2 -2
View File
@@ -6,7 +6,7 @@
<div class="col">
<h2><i class="bi bi-folder2-open"></i> Projects</h2>
</div>
{% if current_user.role in ['admin', 'supervisor'] %}
{% if current_user.role in ['admin', 'director'] %}
<div class="col-auto">
<a href="{{ url_for('projects.create') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> New Project
@@ -52,7 +52,7 @@
<a href="{{ url_for('projects.view', project_id=project.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> View
</a>
{% if current_user.role in ['admin', 'supervisor'] %}
{% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('projects.edit', project_id=project.id) }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil"></i> Edit
</a>
+1 -1
View File
@@ -15,7 +15,7 @@
{% endif %}
</div>
<div class="col-auto d-flex gap-2">
{% if current_user.role in ['admin', 'supervisor'] %}
{% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('projects.edit', project_id=project.id) }}" class="btn btn-outline-secondary">
<i class="bi bi-pencil"></i> Edit
</a>
+4 -4
View File
@@ -8,7 +8,7 @@
<h2><i class="bi bi-file-earmark-text"></i> Inspection Templates</h2>
</div>
<div class="col-md-6 text-end">
{% if current_user.role in ['admin', 'supervisor'] %}
{% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('templates.create_template') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Create Template
</a>
@@ -41,7 +41,7 @@
<i class="bi bi-eye"></i> View
</a>
{% if current_user.role in ['admin', 'supervisor'] %}
{% if current_user.role in ['admin', 'director'] %}
<!-- Rename -->
<button type="button"
class="btn btn-sm btn-outline-secondary"
@@ -90,7 +90,7 @@
{% endfor %}
</div>
{% if current_user.role in ['admin', 'supervisor'] %}
{% if current_user.role in ['admin', 'director'] %}
<!-- Edit Template Modal -->
<div class="modal fade" id="renameModal" tabindex="-1" aria-labelledby="renameModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
@@ -190,7 +190,7 @@
{% endblock %}
{% block extra_js %}
{% if current_user.role in ['admin', 'supervisor'] %}
{% if current_user.role in ['admin', 'director'] %}
<script>
document.addEventListener('DOMContentLoaded', function () {
// Edit template modal
+2 -2
View File
@@ -89,7 +89,7 @@
{% endif %}
</div>
<div class="d-flex gap-2 flex-shrink-0 mt-1">
{% if current_user.role in ['admin', 'supervisor'] %}
{% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('templates.form_editor', template_id=template.id) }}" class="btn btn-primary btn-sm">
<i class="bi bi-pencil-square"></i> Edit Form
</a>
@@ -167,7 +167,7 @@
<div class="empty-state">
<i class="bi bi-layout-text-sidebar-reverse"></i>
<p>No fields defined yet.</p>
{% if current_user.role in ['admin', 'supervisor'] %}
{% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('templates.form_editor', template_id=template.id) }}"
class="btn btn-primary btn-sm mt-3">
<i class="bi bi-plus-circle"></i> Open Form Editor
+12 -5
View File
@@ -12,20 +12,27 @@ def admin_required(f):
return decorated_function
def supervisor_required(f):
"""Grants access to admin and director roles.
The decorator is intentionally kept as 'supervisor_required' so that all
existing route decorators (@supervisor_required) continue to work without
any changes to the route files. The access list now reflects the renamed
Director role instead of the retired Supervisor role.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated or current_user.role not in ['admin', 'supervisor']:
flash('Supervisor access required.', 'danger')
if not current_user.is_authenticated or current_user.role not in ['admin', 'director']:
flash('Director access required.', 'danger')
return redirect(url_for('dashboard.index'))
return f(*args, **kwargs)
return decorated_function
def project_manager_required(f):
"""Grants access to admin, supervisor, and project_manager roles."""
"""Grants access to admin, director, and project_manager roles."""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated or current_user.role not in [
'admin', 'supervisor', 'project_manager'
'admin', 'director', 'project_manager'
]:
flash('Project Manager access required.', 'danger')
return redirect(url_for('dashboard.index'))
@@ -35,7 +42,7 @@ def project_manager_required(f):
def customer_required(f):
"""Restricts access to customer-role users only.
Internal staff (admin, supervisor, inspector, project_manager) should
Internal staff (admin, director, inspector, project_manager) should
never be routed through customer-scoped views use their own routes.
"""
@wraps(f)
+2 -1
View File
@@ -52,9 +52,10 @@ class UserForm(FlaskForm):
confirm_password = PasswordField('Confirm Password', validators=[Optional(), EqualTo('password')])
role = SelectField('Role', choices=[
('admin', 'Administrator'),
('supervisor', 'Supervisor'),
('director', 'Director'),
('inspector', 'Inspector'),
('project_manager', 'Project Manager'),
# 'customer' is intentionally excluded — customer accounts are managed via /customers
], validators=[DataRequired()])
def __init__(self, user=None, *args, **kwargs):
+1 -1
View File
@@ -511,7 +511,7 @@ def notify_by_matrix(
role_to_db = {
'admin': 'admin',
'supervisor': 'supervisor',
'director': 'director',
'inspector': 'inspector',
'project_manager': 'project_manager',
'customer': 'customer',
@@ -0,0 +1,68 @@
"""Phase 11: Rename supervisor role to director
Revision ID: phase11_director_role
Revises: phase10_customer_password_setup
Create Date: 2026-04-09
Changes
-------
1. Adds 'director' to the users.role ENUM.
2. Migrates all existing role='supervisor' users to role='director'.
3. Removes 'supervisor' from the ENUM once no rows use it.
4. Migrates notification_matrix rows keyed role_key='supervisor' 'director'.
"""
from alembic import op
import sqlalchemy as sa
revision = 'phase11_director_role'
down_revision = 'phase10_customer_password_setup'
branch_labels = None
depends_on = None
def upgrade():
# Step 1 — Expand ENUM to include both values (required before UPDATE)
op.execute(
"ALTER TABLE users MODIFY COLUMN role "
"ENUM('admin','supervisor','director','inspector','project_manager','customer') "
"NOT NULL"
)
# Step 2 — Migrate all supervisor users to director
op.execute("UPDATE users SET role = 'director' WHERE role = 'supervisor'")
# Step 3 — Remove 'supervisor' from the ENUM now that no rows reference it
op.execute(
"ALTER TABLE users MODIFY COLUMN role "
"ENUM('admin','director','inspector','project_manager','customer') "
"NOT NULL"
)
# Step 4 — Migrate notification_matrix role_key rows
op.execute(
"UPDATE notification_matrix SET role_key = 'director' WHERE role_key = 'supervisor'"
)
def downgrade():
# Step 1 — Expand ENUM to allow supervisor again
op.execute(
"ALTER TABLE users MODIFY COLUMN role "
"ENUM('admin','supervisor','director','inspector','project_manager','customer') "
"NOT NULL"
)
# Step 2 — Revert director users back to supervisor
op.execute("UPDATE users SET role = 'supervisor' WHERE role = 'director'")
# Step 3 — Remove 'director' from the ENUM
op.execute(
"ALTER TABLE users MODIFY COLUMN role "
"ENUM('admin','supervisor','inspector','project_manager','customer') "
"NOT NULL"
)
# Step 4 — Revert notification_matrix role_key rows
op.execute(
"UPDATE notification_matrix SET role_key = 'supervisor' WHERE role_key = 'director'"
)