From 7d7b4aafa87bd52693560ae8ee55f5fa1d463da1 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Wed, 6 May 2026 12:01:04 -0400 Subject: [PATCH] 05/06 Modify Director's can NOT change user role, Inspector can view own reports --- app/routes/auth.py | 24 +- app/routes/reports.py | 737 ++++++++++++------------------ app/templates/auth/user_form.html | 8 + 3 files changed, 317 insertions(+), 452 deletions(-) diff --git a/app/routes/auth.py b/app/routes/auth.py index c827850..d4cdff4 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -131,12 +131,17 @@ def list_users(): def create_user(): 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=form.role.data + role=role ) user.set_password(form.password.data) db.session.add(user) @@ -148,7 +153,8 @@ def create_user(): 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') + return render_template('auth/user_form.html', form=form, title='Create User', + director_editing=director_editing) @bp.route('/users//edit', methods=['GET', 'POST']) @@ -160,11 +166,18 @@ def edit_user(user_id): 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 - user.role = form.role.data + + if not director_editing: + user.role = form.role.data if 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') 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//delete', methods=['POST']) @@ -317,4 +331,4 @@ def notification_matrix(): matrix_roles = MATRIX_ROLES, defaults = MATRIX_DEFAULTS, state = state, - ) + ) \ No newline at end of file diff --git a/app/routes/reports.py b/app/routes/reports.py index 21defe5..d4cdff4 100644 --- a/app/routes/reports.py +++ b/app/routes/reports.py @@ -1,491 +1,334 @@ -import csv -import io -import logging -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 flask import Blueprint, render_template, redirect, url_for, flash, request, abort +from flask_login import login_user, logout_user, login_required, current_user +from app import db, limiter from app.models.user import User -from app.utils.decorators import supervisor_required -from app.utils.scope import get_customer_scope -from app.utils.audit import log_action, ACTION_EXPORT - -bp = Blueprint('reports', __name__, url_prefix='/reports') +from app.utils.forms import LoginForm, UserForm, ProfileForm +from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url +import logging +from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT logger = logging.getLogger(__name__) - -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 +bp = Blueprint('auth', __name__, url_prefix='/auth') -# ── Overview dashboard ──────────────────────────────────────────────────────── - -@bp.route('/') -@login_required -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') +@bp.route('/login', methods=['GET', 'POST']) +@limiter.limit('20 per minute; 5 per second') +def login(): + if current_user.is_authenticated: 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 - customer_facility_ids = get_customer_scope(current_user) # None = unrestricted - - def _scope_insp(q): - if customer_facility_ids is not None: - if not customer_facility_ids: - return q.filter(False) - return q.filter(Inspection.facility_id.in_(customer_facility_ids)) - return q - - 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) + if user and user.check_password(form.password.data): + if not user.active: + flash('Your account has been disabled. Please contact an administrator.', 'danger') + return render_template('auth/login.html', form=form) + if not user.password_set: + flash( + 'Your account password has not been set yet. ' + 'Please check your email for the account setup link.', + 'warning' ) - ) - return q + return render_template('auth/login.html', form=form) + 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( - 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, - ) + return render_template('auth/login.html', form=form) -# ── Facility detail report ──────────────────────────────────────────────────── - -@bp.route('/facility/') +@bp.route('/logout') @login_required -def facility_report(facility_id): - 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')) - 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) +def logout(): + log_action(ACTION_LOGOUT, 'User', current_user.id, current_user.username) + logout_user() + flash('Successfully logged out.', 'success') + return redirect(url_for('auth.login')) - -# ── Facility Scorecard ──────────────────────────────────────────────────────── - -@bp.route('/facility//scorecard') +@bp.route('/profile', methods=['GET', 'POST']) @login_required -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', 'director', 'project_manager', 'customer']: - from flask import flash, redirect, url_for - flash('Access denied.', 'danger') - return redirect(url_for('dashboard.index')) +def profile(): + """User profile page — view stats and update email/password.""" + from app.models.inspection import Inspection + from app.models.issue import Issue - facility = db.session.get(Facility, facility_id) - if facility is None: - abort(404) + form = ProfileForm(user=current_user, obj=current_user) - 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')) + if form.validate_on_submit(): + current_user.full_name = form.full_name.data.strip() or None + current_user.email = form.email.data - from app.utils.sla import sla_status, SLA_HOURS - from datetime import timedelta + if form.new_password.data: + 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() - days = request.args.get('days', 90, type=int) - if days not in (30, 60, 90, 180, 365): - days = 90 - start = now - timedelta(days=days) + db.session.commit() + logger.info('AUTH | profile_update | user_id=%s username=%s email=%s', + current_user.id, current_user.username, current_user.email) + log_action(ACTION_UPDATE, 'User', current_user.id, current_user.username, + 'self-service profile update') + flash('Profile updated successfully.', 'success') + return redirect(url_for('auth.profile')) - # ── Score trend (daily) ─────────────────────────────────────────────── - trend_rows = 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.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', + # ── Activity stats ──────────────────────────────────────────────────── + total_inspections = Inspection.query.filter_by(inspector_id=current_user.id).count() + completed_inspections = Inspection.query.filter_by( + inspector_id=current_user.id, status='completed' ).count() - # Follow-up required inspections - followup_required = Inspection.query.filter( - Inspection.facility_id == facility_id, - Inspection.follow_up_required == True, - ).order_by(Inspection.inspection_date.desc()).limit(10).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, + recent_inspections = ( + Inspection.query + .filter_by(inspector_id=current_user.id) + .order_by(Inspection.inspection_date.desc()) + .limit(5) + .all() ) -# ── 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 @supervisor_required -def export_inspections(): - start, end = _date_range() - - logger.info( - 'REPORTS | export_inspections | user=%s | range=%s to %s', - current_user.username, - start.strftime('%Y-%m-%d'), - 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}"'} +def list_users(): + # Exclude customer accounts — those are managed exclusively via /customers + users = ( + User.query + .filter(User.role != 'customer') + .order_by(User.created_at.desc()) + .all() ) + 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 @supervisor_required -def export_issues(): - start, end = _date_range() +def create_user(): + 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//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//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//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( - 'REPORTS | export_issues | user=%s | range=%s to %s', - current_user.username, - start.strftime('%Y-%m-%d'), - end.strftime('%Y-%m-%d'), + 'AUTH | user_%s | admin_id=%s admin=%s target_user=%s', + action_label, current_user.id, current_user.username, user.username, ) log_action( - ACTION_EXPORT, 'Issue', None, 'CSV Export', - f'range={start.strftime("%Y-%m-%d")} to {end.strftime("%Y-%m-%d")}', + ACTION_UPDATE, 'User', user.id, user.username, + 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( - Issue.id, - Issue.reported_at, - Facility.name.label('facility'), - Area.name.label('area'), - Issue.severity, - Issue.description, - Issue.status, - Issue.resolved_at, - 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() + if request.method == 'POST': + for event_key in MATRIX_EVENTS: + for role_key, _ in MATRIX_ROLES: + row = NotificationMatrix.query.filter_by( + event_type=event_key, role_key=role_key + ).first() + if row is None: + row = NotificationMatrix(event_type=event_key, role_key=role_key) + db.session.add(row) - def generate(): - buf = io.StringIO() - writer = csv.writer(buf) - writer.writerow(['ID','Reported At','Facility','Area','Severity', - 'Description','Status','Resolved At','Assigned To']) - yield buf.getvalue(); buf.seek(0); buf.truncate() + if role_key == 'custom': + raw = request.form.get(f'custom_{event_key}', '').strip() + # Parse comma-separated emails into a JSON list + emails = [e.strip() for e in raw.split(',') if e.strip()] + row.custom_emails = _json.dumps(emails) + row.enabled = bool(emails) + else: + row.enabled = bool(request.form.get(f'matrix_{event_key}_{role_key}')) - for r in rows: - writer.writerow([ - r.id, - r.reported_at.strftime('%Y-%m-%d %H:%M') if r.reported_at else '', - r.facility, r.area, r.severity, - r.description.replace('\n', ' '), - 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() + db.session.commit() + log_action(ACTION_UPDATE, 'NotificationMatrix', None, + 'Notification Matrix', 'admin updated notification matrix') + logger.info('NOTIFICATION MATRIX UPDATED | by=%s', current_user.username) + flash('Notification matrix saved successfully.', 'success') + return redirect(url_for('auth.notification_matrix')) - filename = f"issues_{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}"'} + # Build current state dict: {event_key: {role_key: enabled/emails}} + all_rows = NotificationMatrix.query.all() + state = {} # event_key -> role_key -> row + for row in all_rows: + 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, ) \ No newline at end of file diff --git a/app/templates/auth/user_form.html b/app/templates/auth/user_form.html index 5ebd61f..1d0658d 100644 --- a/app/templates/auth/user_form.html +++ b/app/templates/auth/user_form.html @@ -103,7 +103,15 @@
{{ form.role.label(class="form-label") }} + {% if director_editing %} + {# Directors can see the current role but cannot change it #} +
+ {{ (user.role if user else 'Inspector').replace('_', ' ')|title }} +
+
Role assignment requires Administrator access.
+ {% else %} {{ form.role(class="form-select") }} + {% endif %}