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( unread = Notification.query.filter_by(
user_id=current_user.id, is_read=False user_id=current_user.id, is_read=False
).count() ).count()
# Pending verification count — only computed for supervisor+ roles # Pending verification count — only computed for director+ roles
pv_count = 0 pv_count = 0
if current_user.role in ('admin', 'supervisor'): if current_user.role in ('admin', 'director'):
pv_count = Issue.query.filter_by( pv_count = Issue.query.filter_by(
status='pending_verification' status='pending_verification'
).count() ).count()
+24 -24
View File
@@ -8,7 +8,7 @@ One row per (event_type, role_key) pair.
role_key values role_key values
--------------- ---------------
admin — all users with role='admin' admin — all users with role='admin'
supervisor — all users with role='supervisor' director — all users with role='director'
inspector — all users with role='inspector' inspector — all users with role='inspector'
project_manager — all users with role='project_manager' project_manager — all users with role='project_manager'
customer — all customer-portal users assigned to the relevant facility 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) Default matrix (mirrors current hardcoded behaviour)
----------------------------------------------------- -----------------------------------------------------
inspection_completed : admin ✓ supervisor ✓ inspector ✗ pm ✗ customer ✓ inspection_completed : admin ✓ director ✓ inspector ✗ pm ✗ customer ✓
issue_assigned : admin ✗ supervisor ✗ inspector ✗ pm ✗ customer ✗ (assignee implicit) issue_assigned : admin ✗ director ✗ inspector ✗ pm ✗ customer ✗ (assignee implicit)
issue_status : admin ✗ supervisor ✗ inspector ✗ pm ✗ customer ✗ (assignee implicit) issue_status : admin ✗ director ✗ inspector ✗ pm ✗ customer ✗ (assignee implicit)
issue_comment : admin ✗ supervisor ✗ inspector ✗ pm ✗ customer ✗ (assignee implicit) issue_comment : admin ✗ director ✗ inspector ✗ pm ✗ customer ✗ (assignee implicit)
issue_follow_update : admin ✗ supervisor ✗ inspector ✗ pm ✗ customer ✗ (followers implicit) issue_follow_update : admin ✗ director ✗ inspector ✗ pm ✗ customer ✗ (followers implicit)
issue_flagged : admin ✗ supervisor ✗ inspector ✗ pm ✗ customer ✓ (assignee implicit) issue_flagged : admin ✗ director ✗ inspector ✗ pm ✗ customer ✓ (assignee implicit)
issue_created : admin ✗ supervisor ✗ inspector ✗ pm ✗ customer ✓ (assignee implicit) issue_created : admin ✗ director ✗ inspector ✗ pm ✗ customer ✓ (assignee implicit)
issue_updated_customer : admin ✗ supervisor ✗ inspector ✗ pm ✗ customer ✓ issue_updated_customer : admin ✗ director ✗ inspector ✗ pm ✗ customer ✓
verification_requested : admin ✓ supervisor ✓ inspector ✗ pm ✗ customer ✗ verification_requested : admin ✓ director ✓ inspector ✗ pm ✗ customer ✗
sla_alert : admin ✓ supervisor ✗ inspector ✗ pm ✗ customer ✗ (assignee + followers implicit) sla_alert : admin ✓ director ✗ inspector ✗ pm ✗ customer ✗ (assignee + followers implicit)
""" """
import json import json
@@ -36,7 +36,7 @@ from app import db
# Role keys available in the matrix UI # Role keys available in the matrix UI
MATRIX_ROLES = [ MATRIX_ROLES = [
('admin', 'Admin'), ('admin', 'Admin'),
('supervisor', 'Supervisor'), ('director', 'Director'),
('inspector', 'Inspector'), ('inspector', 'Inspector'),
('project_manager', 'Project Manager'), ('project_manager', 'Project Manager'),
('customer', 'Customer'), ('customer', 'Customer'),
@@ -65,84 +65,84 @@ MATRIX_EVENTS = {
MATRIX_DEFAULTS = { MATRIX_DEFAULTS = {
# inspection_completed # inspection_completed
('inspection_completed', 'admin'): True, ('inspection_completed', 'admin'): True,
('inspection_completed', 'supervisor'): True, ('inspection_completed', 'director'): True,
('inspection_completed', 'inspector'): False, ('inspection_completed', 'inspector'): False,
('inspection_completed', 'project_manager'): False, ('inspection_completed', 'project_manager'): False,
('inspection_completed', 'customer'): True, ('inspection_completed', 'customer'): True,
('inspection_completed', 'custom'): False, ('inspection_completed', 'custom'): False,
# issue_assigned (assignee is always notified implicitly) # issue_assigned (assignee is always notified implicitly)
('issue_assigned', 'admin'): False, ('issue_assigned', 'admin'): False,
('issue_assigned', 'supervisor'): False, ('issue_assigned', 'director'): False,
('issue_assigned', 'inspector'): False, ('issue_assigned', 'inspector'): False,
('issue_assigned', 'project_manager'): False, ('issue_assigned', 'project_manager'): False,
('issue_assigned', 'customer'): False, ('issue_assigned', 'customer'): False,
('issue_assigned', 'custom'): False, ('issue_assigned', 'custom'): False,
# issue_reassigned # issue_reassigned
('issue_reassigned', 'admin'): False, ('issue_reassigned', 'admin'): False,
('issue_reassigned', 'supervisor'): False, ('issue_reassigned', 'director'): False,
('issue_reassigned', 'inspector'): False, ('issue_reassigned', 'inspector'): False,
('issue_reassigned', 'project_manager'): False, ('issue_reassigned', 'project_manager'): False,
('issue_reassigned', 'customer'): False, ('issue_reassigned', 'customer'): False,
('issue_reassigned', 'custom'): False, ('issue_reassigned', 'custom'): False,
# issue_unassigned # issue_unassigned
('issue_unassigned', 'admin'): False, ('issue_unassigned', 'admin'): False,
('issue_unassigned', 'supervisor'): False, ('issue_unassigned', 'director'): False,
('issue_unassigned', 'inspector'): False, ('issue_unassigned', 'inspector'): False,
('issue_unassigned', 'project_manager'): False, ('issue_unassigned', 'project_manager'): False,
('issue_unassigned', 'customer'): False, ('issue_unassigned', 'customer'): False,
('issue_unassigned', 'custom'): False, ('issue_unassigned', 'custom'): False,
# issue_status # issue_status
('issue_status', 'admin'): False, ('issue_status', 'admin'): False,
('issue_status', 'supervisor'): False, ('issue_status', 'director'): False,
('issue_status', 'inspector'): False, ('issue_status', 'inspector'): False,
('issue_status', 'project_manager'): False, ('issue_status', 'project_manager'): False,
('issue_status', 'customer'): False, ('issue_status', 'customer'): False,
('issue_status', 'custom'): False, ('issue_status', 'custom'): False,
# issue_comment # issue_comment
('issue_comment', 'admin'): False, ('issue_comment', 'admin'): False,
('issue_comment', 'supervisor'): False, ('issue_comment', 'director'): False,
('issue_comment', 'inspector'): False, ('issue_comment', 'inspector'): False,
('issue_comment', 'project_manager'): False, ('issue_comment', 'project_manager'): False,
('issue_comment', 'customer'): False, ('issue_comment', 'customer'): False,
('issue_comment', 'custom'): False, ('issue_comment', 'custom'): False,
# issue_follow_update (followers always notified implicitly) # issue_follow_update (followers always notified implicitly)
('issue_follow_update', 'admin'): False, ('issue_follow_update', 'admin'): False,
('issue_follow_update', 'supervisor'): False, ('issue_follow_update', 'director'): False,
('issue_follow_update', 'inspector'): False, ('issue_follow_update', 'inspector'): False,
('issue_follow_update', 'project_manager'): False, ('issue_follow_update', 'project_manager'): False,
('issue_follow_update', 'customer'): False, ('issue_follow_update', 'customer'): False,
('issue_follow_update', 'custom'): False, ('issue_follow_update', 'custom'): False,
# issue_flagged (from inspection) # issue_flagged (from inspection)
('issue_flagged', 'admin'): False, ('issue_flagged', 'admin'): False,
('issue_flagged', 'supervisor'): False, ('issue_flagged', 'director'): False,
('issue_flagged', 'inspector'): False, ('issue_flagged', 'inspector'): False,
('issue_flagged', 'project_manager'): False, ('issue_flagged', 'project_manager'): False,
('issue_flagged', 'customer'): True, ('issue_flagged', 'customer'): True,
('issue_flagged', 'custom'): False, ('issue_flagged', 'custom'): False,
# issue_created (standalone) # issue_created (standalone)
('issue_created', 'admin'): False, ('issue_created', 'admin'): False,
('issue_created', 'supervisor'): False, ('issue_created', 'director'): False,
('issue_created', 'inspector'): False, ('issue_created', 'inspector'): False,
('issue_created', 'project_manager'): False, ('issue_created', 'project_manager'): False,
('issue_created', 'customer'): True, ('issue_created', 'customer'): True,
('issue_created', 'custom'): False, ('issue_created', 'custom'): False,
# issue_updated_customer # issue_updated_customer
('issue_updated_customer', 'admin'): False, ('issue_updated_customer', 'admin'): False,
('issue_updated_customer', 'supervisor'): False, ('issue_updated_customer', 'director'): False,
('issue_updated_customer', 'inspector'): False, ('issue_updated_customer', 'inspector'): False,
('issue_updated_customer', 'project_manager'): False, ('issue_updated_customer', 'project_manager'): False,
('issue_updated_customer', 'customer'): True, ('issue_updated_customer', 'customer'): True,
('issue_updated_customer', 'custom'): False, ('issue_updated_customer', 'custom'): False,
# verification_requested # verification_requested
('verification_requested', 'admin'): True, ('verification_requested', 'admin'): True,
('verification_requested', 'supervisor'): True, ('verification_requested', 'director'): True,
('verification_requested', 'inspector'): False, ('verification_requested', 'inspector'): False,
('verification_requested', 'project_manager'): False, ('verification_requested', 'project_manager'): False,
('verification_requested', 'customer'): False, ('verification_requested', 'customer'): False,
('verification_requested', 'custom'): False, ('verification_requested', 'custom'): False,
# sla_alert (assignee + followers always notified implicitly) # sla_alert (assignee + followers always notified implicitly)
('sla_alert', 'admin'): True, ('sla_alert', 'admin'): True,
('sla_alert', 'supervisor'): False, ('sla_alert', 'director'): False,
('sla_alert', 'inspector'): False, ('sla_alert', 'inspector'): False,
('sla_alert', 'project_manager'): False, ('sla_alert', 'project_manager'): False,
('sla_alert', 'customer'): 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) email = db.Column(db.String(255), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False) password_hash = db.Column(db.String(255), nullable=False)
role = db.Column( 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 nullable=False
) )
created_at = db.Column(db.DateTime, default=now_eastern) 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 import db
from app.models.user import User from app.models.user import User
from app.utils.forms import LoginForm, UserForm, ProfileForm 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 import logging
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT 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') @bp.route('/users')
@login_required @login_required
@admin_required @supervisor_required
def list_users(): def list_users():
# Exclude customer accounts — those are managed exclusively via /customers # Exclude customer accounts — those are managed exclusively via /customers
users = ( users = (
@@ -143,7 +143,7 @@ def list_users():
@bp.route('/users/new', methods=['GET', 'POST']) @bp.route('/users/new', methods=['GET', 'POST'])
@login_required @login_required
@admin_required @supervisor_required
def create_user(): def create_user():
form = UserForm() form = UserForm()
@@ -169,7 +169,7 @@ def create_user():
@bp.route('/users/<int:user_id>/edit', methods=['GET', 'POST']) @bp.route('/users/<int:user_id>/edit', methods=['GET', 'POST'])
@login_required @login_required
@admin_required @supervisor_required
def edit_user(user_id): def edit_user(user_id):
user = User.query.get_or_404(user_id) user = User.query.get_or_404(user_id)
form = UserForm(user=user, obj=user) form = UserForm(user=user, obj=user)
@@ -196,7 +196,7 @@ def edit_user(user_id):
@bp.route('/users/<int:user_id>/delete', methods=['POST']) @bp.route('/users/<int:user_id>/delete', methods=['POST'])
@login_required @login_required
@admin_required @supervisor_required
def delete_user(user_id): def delete_user(user_id):
user = User.query.get_or_404(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']) @bp.route('/users/<int:user_id>/toggle-active', methods=['POST'])
@login_required @login_required
@admin_required @supervisor_required
def toggle_active(user_id): def toggle_active(user_id):
user = User.query.get_or_404(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.project import Project, CustomerAssignment
from app.models.facility import Facility from app.models.facility import Facility
from app.utils.forms import CustomerUserForm, CustomerAssignmentForm, CustomerInviteForm, SetPasswordForm 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.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
from app.utils.scope import get_customer_scope from app.utils.scope import get_customer_scope
@@ -33,7 +33,7 @@ bp = Blueprint('customers', __name__, url_prefix='/customers')
@bp.route('/') @bp.route('/')
@login_required @login_required
@admin_required @supervisor_required
def index(): def index():
"""Consolidated customer management dashboard.""" """Consolidated customer management dashboard."""
customers = ( customers = (
@@ -101,7 +101,7 @@ def index():
@bp.route('/new', methods=['GET', 'POST']) @bp.route('/new', methods=['GET', 'POST'])
@login_required @login_required
@admin_required @supervisor_required
def create(): def create():
"""Create a customer account via email invitation. """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']) @bp.route('/<int:customer_id>/resend-invite', methods=['POST'])
@login_required @login_required
@admin_required @supervisor_required
def resend_invite(customer_id): def resend_invite(customer_id):
"""Generate a fresh token and resend the set-password invitation email.""" """Generate a fresh token and resend the set-password invitation email."""
customer = User.query.get_or_404(customer_id) 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']) @bp.route('/<int:customer_id>/edit', methods=['GET', 'POST'])
@login_required @login_required
@admin_required @supervisor_required
def edit(customer_id): def edit(customer_id):
customer = User.query.get_or_404(customer_id) customer = User.query.get_or_404(customer_id)
if customer.role != 'customer': if customer.role != 'customer':
@@ -326,7 +326,7 @@ def edit(customer_id):
@bp.route('/<int:customer_id>') @bp.route('/<int:customer_id>')
@login_required @login_required
@admin_required @supervisor_required
def manage(customer_id): def manage(customer_id):
"""Single-customer detail page: profile + all assignments.""" """Single-customer detail page: profile + all assignments."""
customer = User.query.get_or_404(customer_id) 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']) @bp.route('/<int:customer_id>/assignments/add', methods=['POST'])
@login_required @login_required
@admin_required @supervisor_required
def add_assignment(customer_id): def add_assignment(customer_id):
customer = User.query.get_or_404(customer_id) customer = User.query.get_or_404(customer_id)
if customer.role != 'customer': if customer.role != 'customer':
@@ -412,7 +412,7 @@ def add_assignment(customer_id):
@bp.route('/assignments/<int:assignment_id>/remove', methods=['POST']) @bp.route('/assignments/<int:assignment_id>/remove', methods=['POST'])
@login_required @login_required
@admin_required @supervisor_required
def remove_assignment(assignment_id): def remove_assignment(assignment_id):
assignment = CustomerAssignment.query.get_or_404(assignment_id) assignment = CustomerAssignment.query.get_or_404(assignment_id)
customer_id = assignment.user_id customer_id = assignment.user_id
@@ -437,7 +437,7 @@ def remove_assignment(assignment_id):
@bp.route('/<int:customer_id>/toggle-active', methods=['POST']) @bp.route('/<int:customer_id>/toggle-active', methods=['POST'])
@login_required @login_required
@admin_required @supervisor_required
def toggle_active(customer_id): def toggle_active(customer_id):
customer = User.query.get_or_404(customer_id) customer = User.query.get_or_404(customer_id)
if customer.role != 'customer': if customer.role != 'customer':
@@ -463,7 +463,7 @@ def toggle_active(customer_id):
@bp.route('/import/template') @bp.route('/import/template')
@login_required @login_required
@admin_required @supervisor_required
def import_template(): def import_template():
"""Download a blank CSV template showing the expected import format.""" """Download a blank CSV template showing the expected import format."""
import csv, io import csv, io
@@ -495,7 +495,7 @@ def import_template():
@bp.route('/import', methods=['GET', 'POST']) @bp.route('/import', methods=['GET', 'POST'])
@login_required @login_required
@admin_required @supervisor_required
def bulk_import(): def bulk_import():
"""Two-phase CSV import for customer accounts. """Two-phase CSV import for customer accounts.
@@ -744,7 +744,7 @@ def bulk_import():
@bp.route('/facilities-for-project/<int:project_id>') @bp.route('/facilities-for-project/<int:project_id>')
@login_required @login_required
@admin_required @supervisor_required
def facilities_for_project(project_id): def facilities_for_project(project_id):
from flask import jsonify from flask import jsonify
project = Project.query.get_or_404(project_id) 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) thirty_days_ago = now - timedelta(days=30)
is_inspector = current_user.role == 'inspector' 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_customer = current_user.role == 'customer'
is_project_manager = current_user.role == 'project_manager' is_project_manager = current_user.role == 'project_manager'
@@ -107,7 +107,7 @@ def index():
followup_q = followup_q.filter(False) followup_q = followup_q.filter(False)
pending_followups = followup_q.count() 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_facilities = Facility.query.filter_by(active=True).count() if is_privileged else 0
total_templates = InspectionTemplate.query.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 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() form = IssueForm()
areas = Area.query.filter_by(facility_id=inspection.facility_id).order_by(Area.name).all() 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.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] 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')) return redirect(url_for('issues.index'))
form = IssueUpdateForm(obj=issue) 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.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.username) for u in staff]
form.status.data = form.status.data or issue.status form.status.data = form.status.data or issue.status
@@ -124,7 +124,7 @@ def view(issue_id):
issue.status = form.status.data 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 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:
@@ -353,7 +353,7 @@ def unfollow(issue_id):
def create(): def create():
form = IssueForm() form = IssueForm()
areas = Area.query.join(Facility).filter(Facility.active == True).order_by(Facility.name, Area.name).all() 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.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] 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']) @bp.route('/<int:issue_id>/request-verification', methods=['POST'])
@login_required @login_required
def request_verification(issue_id): 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) issue = Issue.query.get_or_404(issue_id)
if current_user.role == 'customer': if current_user.role == 'customer':
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('issues.index')) 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 = ( can_act = (
current_user.role in ['admin', 'supervisor'] current_user.role in ['admin', 'director']
or issue.assigned_to == current_user.id or issue.assigned_to == current_user.id
) )
if not can_act: if not can_act:
@@ -556,7 +556,7 @@ def verification_queue():
def delete(issue_id): def delete(issue_id):
"""Permanently delete an issue and its associated photos. """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. the audit log before the record is removed so there is always a trace.
""" """
issue = Issue.query.get_or_404(issue_id) issue = Issue.query.get_or_404(issue_id)
+4 -4
View File
@@ -38,8 +38,8 @@ def _date_range():
@bp.route('/') @bp.route('/')
@login_required @login_required
def index(): def index():
# Customers get a scoped view; internal staff need supervisor+ access # Customers get a scoped view; internal staff need director+ access
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 from flask import flash, redirect, url_for
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('dashboard.index')) return redirect(url_for('dashboard.index'))
@@ -179,7 +179,7 @@ def index():
@bp.route('/facility/<int:facility_id>') @bp.route('/facility/<int:facility_id>')
@login_required @login_required
def facility_report(facility_id): 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 from flask import flash, redirect, url_for
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('dashboard.index')) return redirect(url_for('dashboard.index'))
@@ -228,7 +228,7 @@ def facility_report(facility_id):
def facility_scorecard(facility_id): def facility_scorecard(facility_id):
"""Comprehensive per-facility scorecard: score trend, SLA compliance, """Comprehensive per-facility scorecard: score trend, SLA compliance,
issue breakdown by severity, inspection frequency.""" 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 from flask import flash, redirect, url_for
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('dashboard.index')) return redirect(url_for('dashboard.index'))
+1 -1
View File
@@ -109,7 +109,7 @@
<strong>{{ entry.username }}</strong> <strong>{{ entry.username }}</strong>
</td> </td>
<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 }} {{ entry.user_role | title }}
</span> </span>
</td> </td>
+1 -1
View File
@@ -86,7 +86,7 @@
<dt class="col-sm-4 text-muted">Role at Time</dt> <dt class="col-sm-4 text-muted">Role at Time</dt>
<dd class="col-sm-8"> <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 }} {{ entry.user_role | title }}
</span> </span>
</dd> </dd>
+2 -2
View File
@@ -28,8 +28,8 @@
<p class="text-muted mb-1" style="font-size:.85rem;">@{{ current_user.username }}</p> <p class="text-muted mb-1" style="font-size:.85rem;">@{{ current_user.username }}</p>
{% endif %} {% endif %}
<p class="text-muted mb-2">{{ current_user.email }}</p> <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 %}"> <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 == 'supervisor' %}star-fill{% else %}person-badge-fill{% endif %} me-1"></i> <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 }} {{ current_user.role | title }}
</span> </span>
<hr> <hr>
+4 -5
View File
@@ -3,12 +3,11 @@
{% block title %}User Management{% endblock %} {% block title %}User Management{% endblock %}
{% block content %} {% block content %}
<div class="row mb-4 align-items-center"> <div class="row mb-4">
<div class="col"> <div class="col-md-6">
<h2><i class="bi bi-people-fill"></i> Internal User Management</h2> <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>
<div class="col-auto"> <div class="col-md-6 text-end">
<a href="{{ url_for('auth.create_user') }}" class="btn btn-primary"> <a href="{{ url_for('auth.create_user') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add User <i class="bi bi-plus-circle"></i> Add User
</a> </a>
@@ -37,7 +36,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 == '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 }} {{ user.role.replace('_',' ')|title }}
</span> </span>
</td> </td>
+7 -5
View File
@@ -101,7 +101,7 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('dashboard.index') }}">Dashboard</a> <a class="nav-link" href="{{ url_for('dashboard.index') }}">Dashboard</a>
</li> </li>
{% if current_user.role in ['admin', 'supervisor', 'project_manager'] %} {% if current_user.role in ['admin', 'director', 'project_manager'] %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('projects.index') }}">Projects</a> <a class="nav-link" href="{{ url_for('projects.index') }}">Projects</a>
</li> </li>
@@ -109,7 +109,7 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('facilities.list_facilities') }}">Facilities</a> <a class="nav-link" href="{{ url_for('facilities.list_facilities') }}">Facilities</a>
</li> </li>
{% if current_user.role != 'customer' %} {% if current_user.role in ['admin', 'director'] %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('templates.index') }}">Templates</a> <a class="nav-link" href="{{ url_for('templates.index') }}">Templates</a>
</li> </li>
@@ -120,7 +120,7 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('reports.index') }}">Reports</a> <a class="nav-link" href="{{ url_for('reports.index') }}">Reports</a>
</li> </li>
{% if current_user.role in ['admin', 'supervisor'] %} {% if current_user.role in ['admin', 'director'] %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('scheduled_reports.index') }}">Scheduled Reports</a> <a class="nav-link" href="{{ url_for('scheduled_reports.index') }}">Scheduled Reports</a>
</li> </li>
@@ -128,7 +128,7 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('issues.index') }}">Issues</a> <a class="nav-link" href="{{ url_for('issues.index') }}">Issues</a>
</li> </li>
{% if current_user.role in ['admin', 'supervisor'] %} {% if current_user.role in ['admin', 'director'] %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link d-flex align-items-center gap-1" <a class="nav-link d-flex align-items-center gap-1"
href="{{ url_for('issues.verification_queue') }}"> href="{{ url_for('issues.verification_queue') }}">
@@ -142,13 +142,15 @@
</a> </a>
</li> </li>
{% endif %} {% endif %}
{% if current_user.role == 'admin' %} {% if current_user.role in ['admin', 'director'] %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.list_users') }}">Users</a> <a class="nav-link" href="{{ url_for('auth.list_users') }}">Users</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('customers.index') }}">Customers</a> <a class="nav-link" href="{{ url_for('customers.index') }}">Customers</a>
</li> </li>
{% endif %}
{% if current_user.role == 'admin' %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('audit.index') }}">Audit Trail</a> <a class="nav-link" href="{{ url_for('audit.index') }}">Audit Trail</a>
</li> </li>
+5 -5
View File
@@ -5,7 +5,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.username }}!</h2> <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 }} {{ current_user.role.replace('_',' ')|title }}
</span> </span>
</div> </div>
@@ -171,8 +171,8 @@
</div> </div>
{% endif %} {% endif %}
{# ── System quick stats (admin/supervisor) ──────────────────────────────── #} {# ── System quick stats (admin/director) ─────────────────────────────────── #}
{% if current_user.role in ['admin', 'supervisor'] %} {% if current_user.role in ['admin', 'director'] %}
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
<div class="col-6 col-md-{% if current_user.role == 'admin' %}4{% else %}6{% endif %}"> <div class="col-6 col-md-{% if current_user.role == 'admin' %}4{% else %}6{% endif %}">
<div class="card shadow-sm text-center"> <div class="card shadow-sm text-center">
@@ -208,7 +208,7 @@
{# ── Score trend chart + Facility performance ───────────────────────────── #} {# ── Score trend chart + Facility performance ───────────────────────────── #}
<div class="row g-3 mb-4"> <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 shadow-sm h-100">
<div class="card-header bg-light fw-semibold"> <div class="card-header bg-light fw-semibold">
<i class="bi bi-graph-up me-1"></i>Inspection Score Trend (Last 30 Days) <i class="bi bi-graph-up me-1"></i>Inspection Score Trend (Last 30 Days)
@@ -226,7 +226,7 @@
</div> </div>
</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="col-lg-5">
<div class="card shadow-sm h-100"> <div class="card shadow-sm h-100">
<div class="card-header bg-light fw-semibold"> <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> <h2><i class="bi bi-building"></i> Facilities</h2>
</div> </div>
<div class="col-md-6 text-end"> <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"> <a href="{{ url_for('facilities.create_facility') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add Facility <i class="bi bi-plus-circle"></i> Add Facility
</a> </a>
+3 -3
View File
@@ -8,13 +8,13 @@
<h2><i class="bi bi-building"></i> {{ facility.name }}</h2> <h2><i class="bi bi-building"></i> {{ facility.name }}</h2>
</div> </div>
<div class="col-md-4 text-end d-flex gap-2 justify-content-end align-items-start"> <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) }}" <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', 'supervisor'] %} {% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('facilities.edit_facility', facility_id=facility.id) }}" class="btn btn-outline-primary"> <a href="{{ url_for('facilities.edit_facility', facility_id=facility.id) }}" class="btn btn-outline-primary">
<i class="bi bi-pencil"></i> Edit <i class="bi bi-pencil"></i> Edit
</a> </a>
@@ -126,7 +126,7 @@
</td> </td>
<td>{{ area.inspections.count() }}</td> <td>{{ area.inspections.count() }}</td>
<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"> <a href="{{ url_for('facilities.edit_area', area_id=area.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</a> </a>
+3 -3
View File
@@ -83,7 +83,7 @@
{% else %} {% else %}
<a href="{{ url_for('inspections.view', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-secondary">View</a> <a href="{{ url_for('inspections.view', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-secondary">View</a>
{% endif %} {% endif %}
{% if current_user.role in ['admin', 'supervisor'] %} {% if current_user.role in ['admin', 'director'] %}
<button type="button" <button type="button"
class="btn btn-sm btn-outline-danger ms-1" class="btn btn-sm btn-outline-danger ms-1"
data-bs-toggle="modal" data-bs-toggle="modal"
@@ -121,7 +121,7 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% if current_user.role in ['admin', 'supervisor'] %} {% if current_user.role in ['admin', 'director'] %}
<!-- Delete Inspection Confirmation Modal --> <!-- Delete Inspection Confirmation Modal -->
<div class="modal fade" id="deleteInspectionModal" tabindex="-1" aria-labelledby="deleteInspectionModalLabel" aria-hidden="true"> <div class="modal fade" id="deleteInspectionModal" tabindex="-1" aria-labelledby="deleteInspectionModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
@@ -155,7 +155,7 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
{% if current_user.role in ['admin', 'supervisor'] %} {% if current_user.role in ['admin', 'director'] %}
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
const modal = document.getElementById('deleteInspectionModal'); const modal = document.getElementById('deleteInspectionModal');
+1 -1
View File
@@ -356,7 +356,7 @@
<i class="bi bi-arrow-repeat"></i> Re-inspect <i class="bi bi-arrow-repeat"></i> Re-inspect
</a> </a>
{% endif %} {% endif %}
{% if current_user.role in ['admin','supervisor'] %} {% if current_user.role in ['admin','director'] %}
{% if not inspection.follow_up_required %} {% if not inspection.follow_up_required %}
<button type="button" class="btn btn-sm btn-outline-warning" <button type="button" class="btn btn-sm btn-outline-warning"
data-bs-toggle="modal" data-bs-target="#followupModal" data-bs-toggle="modal" data-bs-target="#followupModal"
+3 -3
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','supervisor'] %} {% if current_user.role in ['admin','director'] %}
<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>
@@ -100,13 +100,13 @@
<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','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 <i class="bi bi-pencil"></i> Edit
{% else %} {% else %}
<i class="bi bi-eye"></i> View <i class="bi bi-eye"></i> View
{% endif %} {% endif %}
</a> </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) }}" <form method="POST" action="{{ url_for('issues.delete', issue_id=issue.id) }}"
class="d-inline" class="d-inline"
onsubmit="return confirm('Permanently delete Issue #{{ issue.id }}? This cannot be undone.');"> onsubmit="return confirm('Permanently delete Issue #{{ issue.id }}? This cannot be undone.');">
+7 -7
View File
@@ -83,8 +83,8 @@
<hr> <hr>
<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 supervisor verification.</strong> <strong>Awaiting director verification.</strong>
{% if current_user.role in ['admin','supervisor'] %} {% if current_user.role in ['admin','director'] %}
<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">
@@ -163,7 +163,7 @@
</div> </div>
{# ── Update Form ────────────────────────────────────────────────────── #} {# ── 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 %} {% if can_edit %}
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-header bg-light"><h6 class="mb-0">Update Issue</h6></div> <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.label(class="form-label fw-semibold") }}
{{ form.status(class="form-select") }} {{ form.status(class="form-select") }}
</div> </div>
{% if current_user.role in ['admin','supervisor'] %} {% if current_user.role in ['admin','director'] %}
<div class="mb-3"> <div class="mb-3">
{{ form.assigned_to.label(class="form-label fw-semibold") }} {{ form.assigned_to.label(class="form-label fw-semibold") }}
{{ form.assigned_to(class="form-select") }} {{ form.assigned_to(class="form-select") }}
@@ -209,7 +209,7 @@
class="mt-2"> class="mt-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-outline-info w-100" <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 <i class="bi bi-hourglass-split me-1"></i> Request Verification
</button> </button>
</form> </form>
@@ -224,7 +224,7 @@
<a href="{{ url_for('issues.index') }}" class="btn btn-outline-secondary btn-sm"> <a href="{{ url_for('issues.index') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Back to Issues <i class="bi bi-arrow-left"></i> Back to Issues
</a> </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" <button type="button" class="btn btn-outline-danger btn-sm"
data-bs-toggle="modal" data-bs-target="#deleteIssueModal"> data-bs-toggle="modal" data-bs-target="#deleteIssueModal">
<i class="bi bi-trash"></i> Delete Issue <i class="bi bi-trash"></i> Delete Issue
@@ -232,7 +232,7 @@
{% endif %} {% endif %}
</div> </div>
{% if current_user.role in ['admin', 'supervisor'] %} {% if current_user.role in ['admin', 'director'] %}
<!-- Delete Confirmation Modal --> <!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteIssueModal" tabindex="-1" aria-labelledby="deleteIssueModalLabel" aria-hidden="true"> <div class="modal fade" id="deleteIssueModal" tabindex="-1" aria-labelledby="deleteIssueModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
+2 -2
View File
@@ -6,7 +6,7 @@
<div class="col"> <div class="col">
<h2><i class="bi bi-folder2-open"></i> Projects</h2> <h2><i class="bi bi-folder2-open"></i> Projects</h2>
</div> </div>
{% if current_user.role in ['admin', 'supervisor'] %} {% if current_user.role in ['admin', 'director'] %}
<div class="col-auto"> <div class="col-auto">
<a href="{{ url_for('projects.create') }}" class="btn btn-primary"> <a href="{{ url_for('projects.create') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> New Project <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"> <a href="{{ url_for('projects.view', project_id=project.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> View <i class="bi bi-eye"></i> View
</a> </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"> <a href="{{ url_for('projects.edit', project_id=project.id) }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil"></i> Edit <i class="bi bi-pencil"></i> Edit
</a> </a>
+1 -1
View File
@@ -15,7 +15,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="col-auto d-flex gap-2"> <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"> <a href="{{ url_for('projects.edit', project_id=project.id) }}" class="btn btn-outline-secondary">
<i class="bi bi-pencil"></i> Edit <i class="bi bi-pencil"></i> Edit
</a> </a>
+4 -4
View File
@@ -8,7 +8,7 @@
<h2><i class="bi bi-file-earmark-text"></i> Inspection Templates</h2> <h2><i class="bi bi-file-earmark-text"></i> Inspection Templates</h2>
</div> </div>
<div class="col-md-6 text-end"> <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"> <a href="{{ url_for('templates.create_template') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Create Template <i class="bi bi-plus-circle"></i> Create Template
</a> </a>
@@ -41,7 +41,7 @@
<i class="bi bi-eye"></i> View <i class="bi bi-eye"></i> View
</a> </a>
{% if current_user.role in ['admin', 'supervisor'] %} {% if current_user.role in ['admin', 'director'] %}
<!-- Rename --> <!-- Rename -->
<button type="button" <button type="button"
class="btn btn-sm btn-outline-secondary" class="btn btn-sm btn-outline-secondary"
@@ -90,7 +90,7 @@
{% endfor %} {% endfor %}
</div> </div>
{% if current_user.role in ['admin', 'supervisor'] %} {% if current_user.role in ['admin', 'director'] %}
<!-- Edit Template Modal --> <!-- Edit Template Modal -->
<div class="modal fade" id="renameModal" tabindex="-1" aria-labelledby="renameModalLabel" aria-hidden="true"> <div class="modal fade" id="renameModal" tabindex="-1" aria-labelledby="renameModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
@@ -190,7 +190,7 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
{% if current_user.role in ['admin', 'supervisor'] %} {% if current_user.role in ['admin', 'director'] %}
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
// Edit template modal // Edit template modal
+2 -2
View File
@@ -89,7 +89,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="d-flex gap-2 flex-shrink-0 mt-1"> <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"> <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 <i class="bi bi-pencil-square"></i> Edit Form
</a> </a>
@@ -167,7 +167,7 @@
<div class="empty-state"> <div class="empty-state">
<i class="bi bi-layout-text-sidebar-reverse"></i> <i class="bi bi-layout-text-sidebar-reverse"></i>
<p>No fields defined yet.</p> <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) }}" <a href="{{ url_for('templates.form_editor', template_id=template.id) }}"
class="btn btn-primary btn-sm mt-3"> class="btn btn-primary btn-sm mt-3">
<i class="bi bi-plus-circle"></i> Open Form Editor <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 return decorated_function
def supervisor_required(f): 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) @wraps(f)
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):
if not current_user.is_authenticated or current_user.role not in ['admin', 'supervisor']: if not current_user.is_authenticated or current_user.role not in ['admin', 'director']:
flash('Supervisor access required.', 'danger') flash('Director 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 project_manager_required(f): 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) @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', 'supervisor', 'project_manager' 'admin', 'director', 'project_manager'
]: ]:
flash('Project Manager access required.', 'danger') flash('Project Manager access required.', 'danger')
return redirect(url_for('dashboard.index')) return redirect(url_for('dashboard.index'))
@@ -35,7 +42,7 @@ def project_manager_required(f):
def customer_required(f): def customer_required(f):
"""Restricts access to customer-role users only. """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. never be routed through customer-scoped views use their own routes.
""" """
@wraps(f) @wraps(f)
+2 -1
View File
@@ -52,9 +52,10 @@ class UserForm(FlaskForm):
confirm_password = PasswordField('Confirm Password', validators=[Optional(), EqualTo('password')]) confirm_password = PasswordField('Confirm Password', validators=[Optional(), EqualTo('password')])
role = SelectField('Role', choices=[ role = SelectField('Role', choices=[
('admin', 'Administrator'), ('admin', 'Administrator'),
('supervisor', 'Supervisor'), ('director', 'Director'),
('inspector', 'Inspector'), ('inspector', 'Inspector'),
('project_manager', 'Project Manager'), ('project_manager', 'Project Manager'),
# 'customer' is intentionally excluded — customer accounts are managed via /customers
], validators=[DataRequired()]) ], validators=[DataRequired()])
def __init__(self, user=None, *args, **kwargs): def __init__(self, user=None, *args, **kwargs):
+1 -1
View File
@@ -511,7 +511,7 @@ def notify_by_matrix(
role_to_db = { role_to_db = {
'admin': 'admin', 'admin': 'admin',
'supervisor': 'supervisor', 'director': 'director',
'inspector': 'inspector', 'inspector': 'inspector',
'project_manager': 'project_manager', 'project_manager': 'project_manager',
'customer': 'customer', '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'"
)