05/06 Modify Director's can NOT change user role, Inspector can view own reports
This commit is contained in:
+19
-5
@@ -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/<int:user_id>/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/<int:user_id>/delete', methods=['POST'])
|
||||
@@ -317,4 +331,4 @@ def notification_matrix():
|
||||
matrix_roles = MATRIX_ROLES,
|
||||
defaults = MATRIX_DEFAULTS,
|
||||
state = state,
|
||||
)
|
||||
)
|
||||
+290
-447
@@ -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/<int:facility_id>')
|
||||
@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/<int:facility_id>/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/<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(
|
||||
'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,
|
||||
)
|
||||
Reference in New Issue
Block a user