05/06 Modify Director's can NOT change user role, Inspector can view own reports

This commit is contained in:
2026-05-06 12:01:04 -04:00
parent 02b3e1c45d
commit 7d7b4aafa8
3 changed files with 317 additions and 452 deletions
+19 -5
View File
@@ -131,12 +131,17 @@ def list_users():
def create_user(): def create_user():
form = UserForm() form = UserForm()
# Directors may not assign roles — new users created by a director default
# to inspector. Only admins may set an arbitrary role at creation time.
director_editing = current_user.role == 'director'
if form.validate_on_submit(): if form.validate_on_submit():
role = 'inspector' if director_editing else form.role.data
user = User( user = User(
username=form.username.data, username=form.username.data,
full_name=form.full_name.data.strip() or None, full_name=form.full_name.data.strip() or None,
email=form.email.data, email=form.email.data,
role=form.role.data role=role
) )
user.set_password(form.password.data) user.set_password(form.password.data)
db.session.add(user) db.session.add(user)
@@ -148,7 +153,8 @@ def create_user():
flash(f'User {user.username} created successfully.', 'success') flash(f'User {user.username} created successfully.', 'success')
return redirect(url_for('auth.list_users')) return redirect(url_for('auth.list_users'))
return render_template('auth/user_form.html', form=form, title='Create User') return render_template('auth/user_form.html', form=form, title='Create User',
director_editing=director_editing)
@bp.route('/users/<int:user_id>/edit', methods=['GET', 'POST']) @bp.route('/users/<int:user_id>/edit', methods=['GET', 'POST'])
@@ -160,11 +166,18 @@ def edit_user(user_id):
abort(404) abort(404)
form = UserForm(user=user, obj=user) form = UserForm(user=user, obj=user)
# Directors may not change another user's role — that privilege is admin-only.
# The role field is removed from the form for directors so it cannot be
# submitted at all, and the existing role value is preserved on save.
director_editing = current_user.role == 'director'
if form.validate_on_submit(): if form.validate_on_submit():
user.username = form.username.data user.username = form.username.data
user.full_name = form.full_name.data.strip() or None user.full_name = form.full_name.data.strip() or None
user.email = form.email.data user.email = form.email.data
user.role = form.role.data
if not director_editing:
user.role = form.role.data
if form.password.data: if form.password.data:
user.set_password(form.password.data) user.set_password(form.password.data)
@@ -177,7 +190,8 @@ def edit_user(user_id):
flash(f'User {user.username} updated successfully.', 'success') flash(f'User {user.username} updated successfully.', 'success')
return redirect(url_for('auth.list_users')) return redirect(url_for('auth.list_users'))
return render_template('auth/user_form.html', form=form, user=user, title='Edit User') return render_template('auth/user_form.html', form=form, user=user,
title='Edit User', director_editing=director_editing)
@bp.route('/users/<int:user_id>/delete', methods=['POST']) @bp.route('/users/<int:user_id>/delete', methods=['POST'])
@@ -317,4 +331,4 @@ def notification_matrix():
matrix_roles = MATRIX_ROLES, matrix_roles = MATRIX_ROLES,
defaults = MATRIX_DEFAULTS, defaults = MATRIX_DEFAULTS,
state = state, state = state,
) )
+290 -447
View File
@@ -1,491 +1,334 @@
import csv from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
import io from flask_login import login_user, logout_user, login_required, current_user
import logging from app import db, limiter
from datetime import datetime, timedelta
from app.utils.time_utils import now_eastern
from flask import (Blueprint, render_template, request,
Response, stream_with_context, abort)
from flask_login import login_required, current_user
from sqlalchemy import func
from app import db
from app.models.inspection import Inspection, InspectionTemplate
from app.models.facility import Facility, Area
from app.models.issue import Issue
from app.models.user import User from app.models.user import User
from app.utils.decorators import supervisor_required from app.utils.forms import LoginForm, UserForm, ProfileForm
from app.utils.scope import get_customer_scope from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url
from app.utils.audit import log_action, ACTION_EXPORT import logging
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT
bp = Blueprint('reports', __name__, url_prefix='/reports')
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
bp = Blueprint('auth', __name__, url_prefix='/auth')
def _date_range():
"""Parse ?start= and ?end= query params; default to last 30 days."""
end_default = now_eastern()
start_default = end_default - timedelta(days=30)
try:
start = datetime.strptime(request.args.get('start', ''), '%Y-%m-%d')
except ValueError:
start = start_default
try:
end = datetime.strptime(request.args.get('end', ''), '%Y-%m-%d')
end = end.replace(hour=23, minute=59, second=59)
except ValueError:
end = end_default
return start, end
# ── Overview dashboard ──────────────────────────────────────────────────────── @bp.route('/login', methods=['GET', 'POST'])
@limiter.limit('20 per minute; 5 per second')
@bp.route('/') def login():
@login_required if current_user.is_authenticated:
def index():
# 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')) return redirect(url_for('dashboard.index'))
start, end = _date_range() form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data).first()
# Resolve facility scope for customers if user and user.check_password(form.password.data):
customer_facility_ids = get_customer_scope(current_user) # None = unrestricted if not user.active:
flash('Your account has been disabled. Please contact an administrator.', 'danger')
def _scope_insp(q): return render_template('auth/login.html', form=form)
if customer_facility_ids is not None: if not user.password_set:
if not customer_facility_ids: flash(
return q.filter(False) 'Your account password has not been set yet. '
return q.filter(Inspection.facility_id.in_(customer_facility_ids)) 'Please check your email for the account setup link.',
return q 'warning'
def _scope_issue(q):
if customer_facility_ids is not None:
if not customer_facility_ids:
return q.filter(False)
return q.outerjoin(Area, Issue.area_id == Area.id).filter(
db.or_(
Issue.facility_id.in_(customer_facility_ids),
Area.facility_id.in_(customer_facility_ids)
) )
) return render_template('auth/login.html', form=form)
return q login_user(user, remember=form.remember_me.data)
# Use validated next URL — never redirect blindly to request.args['next']
next_page = safe_redirect_url(request.args.get('next'))
log_action(ACTION_LOGIN, 'User', user.id, user.username)
flash(f'Welcome back, {user.username}!', 'success')
return redirect(next_page)
else:
# Generic message — don't reveal whether the username exists
flash('Invalid credentials. Please try again.', 'danger')
base = _scope_insp(Inspection.query.filter( return render_template('auth/login.html', form=form)
Inspection.inspection_date >= start,
Inspection.inspection_date <= end,
))
total_inspections = base.count()
completed = base.filter(Inspection.status == 'completed').count()
flagged = _scope_issue(Issue.query.filter(
Issue.reported_at >= start,
Issue.reported_at <= end,
Issue.status != 'resolved',
)).count()
avg_score = db.session.query(func.avg(Inspection.overall_score)).filter(
Inspection.inspection_date >= start,
Inspection.inspection_date <= end,
Inspection.status == 'completed',
Inspection.overall_score.isnot(None),
)
avg_score = _scope_insp(avg_score).scalar()
# Scores by facility (for bar chart)
fac_score_q = db.session.query(
Facility.name,
func.avg(Inspection.overall_score).label('avg_score'),
func.count(Inspection.id).label('count'),
).join(Inspection, Facility.id == Inspection.facility_id)\
.filter(
Inspection.inspection_date >= start,
Inspection.inspection_date <= end,
Inspection.status == 'completed',
Inspection.overall_score.isnot(None),
)
if customer_facility_ids is not None:
fac_score_q = fac_score_q.filter(
Facility.id.in_(customer_facility_ids) if customer_facility_ids else False
)
facility_scores = fac_score_q.group_by(Facility.id, Facility.name)\
.order_by(func.avg(Inspection.overall_score).desc()).all()
# Score trend — daily averages (line chart)
daily_q = db.session.query(
func.date(Inspection.inspection_date).label('day'),
func.avg(Inspection.overall_score).label('avg'),
func.count(Inspection.id).label('count'),
).filter(
Inspection.inspection_date >= start,
Inspection.inspection_date <= end,
Inspection.status == 'completed',
Inspection.overall_score.isnot(None),
)
daily_scores = _scope_insp(daily_q).group_by(func.date(Inspection.inspection_date))\
.order_by(func.date(Inspection.inspection_date)).all()
# Issue breakdown by severity
issue_severity = _scope_issue(db.session.query(
Issue.severity,
func.count(Issue.id).label('count'),
).filter(
Issue.reported_at >= start,
Issue.reported_at <= end,
)).group_by(Issue.severity).all()
# Issue status breakdown
issue_status = _scope_issue(db.session.query(
Issue.status,
func.count(Issue.id).label('count'),
).filter(
Issue.reported_at >= start,
Issue.reported_at <= end,
)).group_by(Issue.status).all()
# Top inspectors by inspection count (hidden for customer role)
top_inspectors = []
if current_user.role != 'customer':
top_inspectors = db.session.query(
User.username,
func.count(Inspection.id).label('count'),
func.avg(Inspection.overall_score).label('avg_score'),
).join(Inspection, User.id == Inspection.inspector_id)\
.filter(
Inspection.inspection_date >= start,
Inspection.inspection_date <= end,
Inspection.status == 'completed',
).group_by(User.id, User.username)\
.order_by(func.count(Inspection.id).desc()).limit(10).all()
# Recent issues (critical/high) — scoped for customers
critical_issues = _scope_issue(Issue.query.filter(
Issue.severity.in_(['critical', 'high']),
Issue.status != 'resolved',
Issue.reported_at >= start,
Issue.reported_at <= end,
)).order_by(Issue.reported_at.desc()).limit(10).all()
return render_template('reports/index.html',
start=start, end=end,
total_inspections=total_inspections,
completed=completed,
flagged=flagged,
avg_score=round(float(avg_score), 2) if avg_score else None,
facility_scores=[{'name': r.name, 'avg_score': round(float(r.avg_score), 2), 'count': r.count} for r in facility_scores],
daily_scores=[{'day': str(r.day), 'avg': round(float(r.avg), 2), 'count': r.count} for r in daily_scores],
issue_severity=[{'severity': r.severity, 'count': r.count} for r in issue_severity],
issue_status=[{'status': r.status, 'count': r.count} for r in issue_status],
top_inspectors=[{'username': r.username, 'count': r.count, 'avg_score': round(float(r.avg_score), 2) if r.avg_score else None} for r in top_inspectors],
critical_issues=critical_issues,
)
# ── Facility detail report ──────────────────────────────────────────────────── @bp.route('/logout')
@bp.route('/facility/<int:facility_id>')
@login_required @login_required
def facility_report(facility_id): def logout():
if current_user.role not in ['admin', 'director', 'project_manager', 'customer']: log_action(ACTION_LOGOUT, 'User', current_user.id, current_user.username)
from flask import flash, redirect, url_for logout_user()
flash('Access denied.', 'danger') flash('Successfully logged out.', 'success')
return redirect(url_for('dashboard.index')) return redirect(url_for('auth.login'))
facility = db.session.get(Facility, facility_id)
if facility is None:
abort(404)
if current_user.role == 'customer':
cids = get_customer_scope(current_user) or []
if facility_id not in cids:
from flask import flash, redirect, url_for
flash('Access denied.', 'danger')
return redirect(url_for('reports.index'))
start, end = _date_range()
inspections = Inspection.query.filter(
Inspection.facility_id == facility_id,
Inspection.inspection_date >= start,
Inspection.inspection_date <= end,
).order_by(Inspection.inspection_date.desc()).all()
area_scores = db.session.query(
Area.name,
func.avg(Inspection.overall_score).label('avg_score'),
func.count(Inspection.id).label('count'),
).join(Inspection, Area.id == Inspection.area_id)\
.filter(
Inspection.facility_id == facility_id,
Inspection.inspection_date >= start,
Inspection.inspection_date <= end,
Inspection.status == 'completed',
).group_by(Area.id, Area.name).all()
open_issues = Issue.query.join(Area)\
.filter(Area.facility_id == facility_id, Issue.status != 'resolved')\
.order_by(Issue.severity.desc()).all()
return render_template('reports/facility.html',
facility=facility, inspections=inspections,
area_scores=area_scores, open_issues=open_issues,
start=start, end=end)
@bp.route('/profile', methods=['GET', 'POST'])
# ── Facility Scorecard ────────────────────────────────────────────────────────
@bp.route('/facility/<int:facility_id>/scorecard')
@login_required @login_required
def facility_scorecard(facility_id): def profile():
"""Comprehensive per-facility scorecard: score trend, SLA compliance, """User profile page — view stats and update email/password."""
issue breakdown by severity, inspection frequency.""" from app.models.inspection import Inspection
if current_user.role not in ['admin', 'director', 'project_manager', 'customer']: from app.models.issue import Issue
from flask import flash, redirect, url_for
flash('Access denied.', 'danger')
return redirect(url_for('dashboard.index'))
facility = db.session.get(Facility, facility_id) form = ProfileForm(user=current_user, obj=current_user)
if facility is None:
abort(404)
if current_user.role == 'customer': if form.validate_on_submit():
cids = get_customer_scope(current_user) or [] current_user.full_name = form.full_name.data.strip() or None
if facility_id not in cids: current_user.email = form.email.data
from flask import flash, redirect, url_for
flash('Access denied.', 'danger')
return redirect(url_for('reports.index'))
from app.utils.sla import sla_status, SLA_HOURS if form.new_password.data:
from datetime import timedelta current_user.set_password(form.new_password.data)
logger.info('AUTH | profile_password_change | user_id=%s username=%s',
current_user.id, current_user.username)
now = now_eastern() db.session.commit()
days = request.args.get('days', 90, type=int) logger.info('AUTH | profile_update | user_id=%s username=%s email=%s',
if days not in (30, 60, 90, 180, 365): current_user.id, current_user.username, current_user.email)
days = 90 log_action(ACTION_UPDATE, 'User', current_user.id, current_user.username,
start = now - timedelta(days=days) 'self-service profile update')
flash('Profile updated successfully.', 'success')
return redirect(url_for('auth.profile'))
# ── Score trend (daily) ─────────────────────────────────────────────── # ── Activity stats ────────────────────────────────────────────────────
trend_rows = db.session.query( total_inspections = Inspection.query.filter_by(inspector_id=current_user.id).count()
func.date(Inspection.inspection_date).label('day'), completed_inspections = Inspection.query.filter_by(
func.avg(Inspection.overall_score).label('avg'), inspector_id=current_user.id, status='completed'
func.count(Inspection.id).label('count'),
).filter(
Inspection.facility_id == facility_id,
Inspection.inspection_date >= start,
Inspection.status == 'completed',
Inspection.overall_score.isnot(None),
).group_by(func.date(Inspection.inspection_date)) .order_by(func.date(Inspection.inspection_date)).all()
trend_labels = [str(r.day) for r in trend_rows]
trend_data = [round(float(r.avg), 2) for r in trend_rows]
# ── KPI summary ───────────────────────────────────────────────────────
all_insp = Inspection.query.filter(
Inspection.facility_id == facility_id,
Inspection.inspection_date >= start,
).all()
completed_insp = [i for i in all_insp if i.status == 'completed']
avg_score = (
round(sum(float(i.overall_score) for i in completed_insp
if i.overall_score is not None)
/ len([i for i in completed_insp if i.overall_score is not None]), 2)
if any(i.overall_score for i in completed_insp) else None
)
# ── Area scores ───────────────────────────────────────────────────────
area_scores = db.session.query(
Area.name,
func.avg(Inspection.overall_score).label('avg'),
func.count(Inspection.id).label('count'),
).join(Inspection, Area.id == Inspection.area_id) .filter(
Inspection.facility_id == facility_id,
Inspection.inspection_date >= start,
Inspection.status == 'completed',
Inspection.overall_score.isnot(None),
).group_by(Area.id, Area.name) .order_by(func.avg(Inspection.overall_score).desc()).all()
# ── Open issues ───────────────────────────────────────────────────────
open_issues = Issue.query.join(Area) .filter(Area.facility_id == facility_id, Issue.status != 'resolved') .order_by(Issue.reported_at.desc()).all()
# SLA compliance for closed issues in window
closed_issues = Issue.query.join(Area).filter(
Area.facility_id == facility_id,
Issue.status == 'resolved',
Issue.reported_at >= start,
).all()
sla_met = sum(1 for i in closed_issues
if i.resolved_at and i.reported_at
and (i.resolved_at - i.reported_at).total_seconds() / 3600
<= SLA_HOURS.get(i.severity, 9999))
sla_total = len(closed_issues)
sla_pct = round(sla_met / sla_total * 100, 1) if sla_total else None
# Issue severity breakdown
sev_counts = {}
for sev in ('critical', 'high', 'medium', 'low'):
sev_counts[sev] = Issue.query.join(Area).filter(
Area.facility_id == facility_id,
Issue.severity == sev,
Issue.status != 'resolved',
).count()
# Pending verification count
pending_verification = Issue.query.join(Area).filter(
Area.facility_id == facility_id,
Issue.status == 'pending_verification',
).count() ).count()
# Follow-up required inspections recent_inspections = (
followup_required = Inspection.query.filter( Inspection.query
Inspection.facility_id == facility_id, .filter_by(inspector_id=current_user.id)
Inspection.follow_up_required == True, .order_by(Inspection.inspection_date.desc())
).order_by(Inspection.inspection_date.desc()).limit(10).all() .limit(5)
.all()
return render_template('reports/scorecard.html',
facility = facility,
days = days,
start = start,
now = now,
total_inspections = len(all_insp),
completed_insp = len(completed_insp),
avg_score = avg_score,
trend_labels = trend_labels,
trend_data = trend_data,
area_scores = area_scores,
open_issues = open_issues,
sla_pct = sla_pct,
sla_met = sla_met,
sla_total = sla_total,
sev_counts = sev_counts,
pending_verification = pending_verification,
followup_required = followup_required,
) )
# ── CSV export ──────────────────────────────────────────────────────────────── open_issues = Issue.query.filter_by(
assigned_to=current_user.id, status='open'
).count() if hasattr(Issue, 'assigned_to') else 0
@bp.route('/export/inspections') return render_template(
'auth/profile.html',
form=form,
total_inspections=total_inspections,
completed_inspections=completed_inspections,
recent_inspections=recent_inspections,
open_issues=open_issues,
)
@bp.route('/users')
@login_required @login_required
@supervisor_required @supervisor_required
def export_inspections(): def list_users():
start, end = _date_range() # Exclude customer accounts — those are managed exclusively via /customers
users = (
logger.info( User.query
'REPORTS | export_inspections | user=%s | range=%s to %s', .filter(User.role != 'customer')
current_user.username, .order_by(User.created_at.desc())
start.strftime('%Y-%m-%d'), .all()
end.strftime('%Y-%m-%d'),
)
log_action(
ACTION_EXPORT, 'Inspection', None, 'CSV Export',
f'range={start.strftime("%Y-%m-%d")} to {end.strftime("%Y-%m-%d")}',
)
rows = db.session.query(
Inspection.id,
Inspection.inspection_date,
Facility.name.label('facility'),
Area.name.label('area'),
User.username.label('inspector'),
InspectionTemplate.name.label('template'),
Inspection.overall_score,
Inspection.status,
Inspection.completed_at,
Inspection.notes,
).join(Facility, Inspection.facility_id == Facility.id)\
.outerjoin(Area, Inspection.area_id == Area.id)\
.join(User, Inspection.inspector_id == User.id)\
.join(InspectionTemplate, Inspection.template_id == InspectionTemplate.id)\
.filter(
Inspection.inspection_date >= start,
Inspection.inspection_date <= end,
).order_by(Inspection.inspection_date.desc()).all()
def generate():
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(['ID','Date','Facility','Area','Inspector','Template',
'Score','Status','Completed At','Notes'])
yield buf.getvalue(); buf.seek(0); buf.truncate()
for r in rows:
writer.writerow([
r.id,
r.inspection_date.strftime('%Y-%m-%d %H:%M') if r.inspection_date else '',
r.facility, r.area or '',
r.inspector, r.template,
r.overall_score or '',
r.status,
r.completed_at.strftime('%Y-%m-%d %H:%M') if r.completed_at else '',
(r.notes or '').replace('\n', ' '),
])
yield buf.getvalue(); buf.seek(0); buf.truncate()
filename = f"inspections_{start.strftime('%Y%m%d')}_{end.strftime('%Y%m%d')}.csv"
return Response(
stream_with_context(generate()),
mimetype='text/csv',
headers={'Content-Disposition': f'attachment; filename="{filename}"'}
) )
logger.info('AUTH | list_users | admin=%s | internal_users_count=%s',
current_user.username, len(users))
return render_template('auth/users.html', users=users)
@bp.route('/export/issues') @bp.route('/users/new', methods=['GET', 'POST'])
@login_required @login_required
@supervisor_required @supervisor_required
def export_issues(): def create_user():
start, end = _date_range() form = UserForm()
# Directors may not assign roles — new users created by a director default
# to inspector. Only admins may set an arbitrary role at creation time.
director_editing = current_user.role == 'director'
if form.validate_on_submit():
role = 'inspector' if director_editing else form.role.data
user = User(
username=form.username.data,
full_name=form.full_name.data.strip() or None,
email=form.email.data,
role=role
)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
logger.info('AUTH | user_create | admin_id=%s admin=%s new_user=%s role=%s',
current_user.id, current_user.username, user.username, user.role)
log_action(ACTION_CREATE, 'User', user.id, user.username,
f'role={user.role}; email={user.email}')
flash(f'User {user.username} created successfully.', 'success')
return redirect(url_for('auth.list_users'))
return render_template('auth/user_form.html', form=form, title='Create User',
director_editing=director_editing)
@bp.route('/users/<int:user_id>/edit', methods=['GET', 'POST'])
@login_required
@supervisor_required
def edit_user(user_id):
user = db.session.get(User, user_id)
if user is None:
abort(404)
form = UserForm(user=user, obj=user)
# Directors may not change another user's role — that privilege is admin-only.
# The role field is removed from the form for directors so it cannot be
# submitted at all, and the existing role value is preserved on save.
director_editing = current_user.role == 'director'
if form.validate_on_submit():
user.username = form.username.data
user.full_name = form.full_name.data.strip() or None
user.email = form.email.data
if not director_editing:
user.role = form.role.data
if form.password.data:
user.set_password(form.password.data)
db.session.commit()
logger.info('AUTH | user_edit | admin_id=%s admin=%s target_user_id=%s target_user=%s',
current_user.id, current_user.username, user.id, user.username)
log_action(ACTION_UPDATE, 'User', user.id, user.username,
f'role={user.role}; email={user.email}')
flash(f'User {user.username} updated successfully.', 'success')
return redirect(url_for('auth.list_users'))
return render_template('auth/user_form.html', form=form, user=user,
title='Edit User', director_editing=director_editing)
@bp.route('/users/<int:user_id>/delete', methods=['POST'])
@login_required
@supervisor_required
def delete_user(user_id):
user = db.session.get(User, user_id)
if user is None:
abort(404)
if user.id == current_user.id:
flash('Cannot delete your own account.', 'danger')
return redirect(url_for('auth.list_users'))
# Guard: block deletion if user has related records that would orphan data
# or violate FK constraints. Issue.assigned_to and IssueComment.user_id carry
# no ondelete clause, so MySQL defaults to RESTRICT — the DELETE would fail at
# the DB level without these application-level checks and clear user-facing messages.
if user.inspections.count() > 0:
flash(
f'Cannot delete "{user.username}" — they have existing inspection records. '
'Deactivate the account instead.',
'danger'
)
return redirect(url_for('auth.list_users'))
if user.assigned_issues.count() > 0:
flash(
f'Cannot delete "{user.username}" — they have issues assigned to them. '
'Reassign or resolve those issues first, then deactivate the account.',
'danger'
)
return redirect(url_for('auth.list_users'))
from app.models.issue import IssueComment
if IssueComment.query.filter_by(user_id=user.id).count() > 0:
flash(
f'Cannot delete "{user.username}" — they have authored issue comments. '
'Deactivate the account instead.',
'danger'
)
return redirect(url_for('auth.list_users'))
from app.models.inspection import InspectionTemplate
if InspectionTemplate.query.filter_by(created_by=user.id).count() > 0:
flash(
f'Cannot delete "{user.username}" — they have created inspection templates. '
'Deactivate the account instead.',
'danger'
)
return redirect(url_for('auth.list_users'))
username = user.username
user_id = user.id
db.session.delete(user)
db.session.commit()
logger.info('AUTH | user_delete | admin_id=%s admin=%s deleted_user=%s',
current_user.id, current_user.username, username)
log_action(ACTION_DELETE, 'User', user_id, username)
flash(f'User {username} deleted successfully.', 'success')
return redirect(url_for('auth.list_users'))
@bp.route('/users/<int:user_id>/toggle-active', methods=['POST'])
@login_required
@supervisor_required
def toggle_active(user_id):
user = db.session.get(User, user_id)
if user is None:
abort(404)
if user.id == current_user.id:
flash('You cannot disable your own account.', 'danger')
return redirect(url_for('auth.list_users'))
user.active = not user.active
db.session.commit()
action_label = 'enabled' if user.active else 'disabled'
logger.info( logger.info(
'REPORTS | export_issues | user=%s | range=%s to %s', 'AUTH | user_%s | admin_id=%s admin=%s target_user=%s',
current_user.username, action_label, current_user.id, current_user.username, user.username,
start.strftime('%Y-%m-%d'),
end.strftime('%Y-%m-%d'),
) )
log_action( log_action(
ACTION_EXPORT, 'Issue', None, 'CSV Export', ACTION_UPDATE, 'User', user.id, user.username,
f'range={start.strftime("%Y-%m-%d")} to {end.strftime("%Y-%m-%d")}', f'account {action_label} by {current_user.username}',
)
flash(f'User {user.username} has been {action_label}.', 'success')
return redirect(safe_redirect_url(request.referrer, fallback=url_for('auth.list_users')))
# ── Notification Matrix ───────────────────────────────────────────────────────
@bp.route('/notification-matrix', methods=['GET', 'POST'])
@login_required
@admin_required
def notification_matrix():
"""Admin-only notification matrix — controls who receives each event type."""
import json as _json
from app.models.notification_matrix import (
NotificationMatrix, MATRIX_EVENTS, MATRIX_ROLES, MATRIX_DEFAULTS,
) )
rows = db.session.query( if request.method == 'POST':
Issue.id, for event_key in MATRIX_EVENTS:
Issue.reported_at, for role_key, _ in MATRIX_ROLES:
Facility.name.label('facility'), row = NotificationMatrix.query.filter_by(
Area.name.label('area'), event_type=event_key, role_key=role_key
Issue.severity, ).first()
Issue.description, if row is None:
Issue.status, row = NotificationMatrix(event_type=event_key, role_key=role_key)
Issue.resolved_at, db.session.add(row)
User.username.label('assigned_to'),
).outerjoin(Area, Issue.area_id == Area.id)\
.outerjoin(Facility, db.or_(
Facility.id == Area.facility_id,
Facility.id == Issue.facility_id
))\
.outerjoin(User, Issue.assigned_to == User.id)\
.filter(
Issue.reported_at >= start,
Issue.reported_at <= end,
).order_by(Issue.reported_at.desc()).all()
def generate(): if role_key == 'custom':
buf = io.StringIO() raw = request.form.get(f'custom_{event_key}', '').strip()
writer = csv.writer(buf) # Parse comma-separated emails into a JSON list
writer.writerow(['ID','Reported At','Facility','Area','Severity', emails = [e.strip() for e in raw.split(',') if e.strip()]
'Description','Status','Resolved At','Assigned To']) row.custom_emails = _json.dumps(emails)
yield buf.getvalue(); buf.seek(0); buf.truncate() row.enabled = bool(emails)
else:
row.enabled = bool(request.form.get(f'matrix_{event_key}_{role_key}'))
for r in rows: db.session.commit()
writer.writerow([ log_action(ACTION_UPDATE, 'NotificationMatrix', None,
r.id, 'Notification Matrix', 'admin updated notification matrix')
r.reported_at.strftime('%Y-%m-%d %H:%M') if r.reported_at else '', logger.info('NOTIFICATION MATRIX UPDATED | by=%s', current_user.username)
r.facility, r.area, r.severity, flash('Notification matrix saved successfully.', 'success')
r.description.replace('\n', ' '), return redirect(url_for('auth.notification_matrix'))
r.status,
r.resolved_at.strftime('%Y-%m-%d %H:%M') if r.resolved_at else '',
r.assigned_to or '',
])
yield buf.getvalue(); buf.seek(0); buf.truncate()
filename = f"issues_{start.strftime('%Y%m%d')}_{end.strftime('%Y%m%d')}.csv" # Build current state dict: {event_key: {role_key: enabled/emails}}
return Response( all_rows = NotificationMatrix.query.all()
stream_with_context(generate()), state = {} # event_key -> role_key -> row
mimetype='text/csv', for row in all_rows:
headers={'Content-Disposition': f'attachment; filename="{filename}"'} state.setdefault(row.event_type, {})[row.role_key] = row
return render_template(
'auth/notification_matrix.html',
matrix_events = MATRIX_EVENTS,
matrix_roles = MATRIX_ROLES,
defaults = MATRIX_DEFAULTS,
state = state,
) )
+8
View File
@@ -103,7 +103,15 @@
<div class="mb-4"> <div class="mb-4">
{{ form.role.label(class="form-label") }} {{ form.role.label(class="form-label") }}
{% if director_editing %}
{# Directors can see the current role but cannot change it #}
<div class="form-control bg-light text-muted" style="cursor: not-allowed;">
{{ (user.role if user else 'Inspector').replace('_', ' ')|title }}
</div>
<div class="form-text">Role assignment requires Administrator access.</div>
{% else %}
{{ form.role(class="form-select") }} {{ form.role(class="form-select") }}
{% endif %}
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">