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

This commit is contained in:
2026-05-06 12:14:46 -04:00
parent 7d7b4aafa8
commit c2d63b61a3
2 changed files with 503 additions and 292 deletions
+494 -288
View File
@@ -1,334 +1,540 @@
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.forms import LoginForm, UserForm, ProfileForm
from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url
import csv
import io
import logging
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT
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.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')
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
@bp.route('/login', methods=['GET', 'POST'])
@limiter.limit('20 per minute; 5 per second')
def login():
if current_user.is_authenticated:
# ── Overview dashboard ────────────────────────────────────────────────────────
@bp.route('/')
@login_required
def index():
# Inspectors get a scoped view of their own inspections and related issues.
# Customers get a facility-scoped view.
# Internal management roles (director+) get the full unscoped view.
if current_user.role not in ['admin', 'director', 'project_manager', 'inspector', 'customer']:
from flask import flash, redirect, url_for
flash('Access denied.', 'danger')
return redirect(url_for('dashboard.index'))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data).first()
start, end = _date_range()
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'
# Resolve scoping for customers (facility list) and inspectors (inspector_id)
customer_facility_ids = get_customer_scope(current_user) # None = unrestricted
is_inspector = current_user.role == 'inspector'
# Pre-compute the set of inspection IDs conducted by this inspector.
# Used in _scope_issue to avoid adding a join to queries that already
# have their own joins (e.g. issue_severity, issue_status group-by queries).
inspector_inspection_ids = []
if is_inspector:
inspector_inspection_ids = [
row[0] for row in
db.session.query(Inspection.id)
.filter(Inspection.inspector_id == current_user.id)
.all()
]
def _scope_insp(q):
if is_inspector:
return q.filter(Inspection.inspector_id == current_user.id)
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 is_inspector:
# Scope to issues flagged during this inspector's own inspections.
# Uses a pre-computed ID list (subquery) to avoid join conflicts
# with queries that already carry their own joins/group-by clauses.
if not inspector_inspection_ids:
return q.filter(False)
return q.filter(Issue.inspection_id.in_(inspector_inspection_ids))
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)
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')
)
return q
return render_template('auth/login.html', form=form)
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 — inspector sees only their own row
top_inspectors = []
if current_user.role != 'customer':
top_insp_q = 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',
)
if is_inspector:
top_insp_q = top_insp_q.filter(Inspection.inspector_id == current_user.id)
top_inspectors = top_insp_q.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,
is_inspector=is_inspector,
)
@bp.route('/logout')
# ── Facility detail report ────────────────────────────────────────────────────
@bp.route('/facility/<int:facility_id>')
@login_required
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'))
def facility_report(facility_id):
if current_user.role not in ['admin', 'director', 'project_manager', 'inspector', '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'))
if current_user.role == 'inspector':
# Inspectors may only view the facility report for facilities where
# they have personally conducted at least one inspection.
has_access = Inspection.query.filter_by(
facility_id=facility_id,
inspector_id=current_user.id,
).first()
if not has_access:
from flask import flash, redirect, url_for
flash('Access denied. You have not conducted inspections at this facility.', '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
def profile():
"""User profile page — view stats and update email/password."""
from app.models.inspection import Inspection
from app.models.issue import Issue
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', 'inspector', 'customer']:
from flask import flash, redirect, url_for
flash('Access denied.', 'danger')
return redirect(url_for('dashboard.index'))
form = ProfileForm(user=current_user, obj=current_user)
facility = db.session.get(Facility, facility_id)
if facility is None:
abort(404)
if form.validate_on_submit():
current_user.full_name = form.full_name.data.strip() or None
current_user.email = form.email.data
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.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)
if current_user.role == 'inspector':
has_access = Inspection.query.filter_by(
facility_id=facility_id,
inspector_id=current_user.id,
).first()
if not has_access:
from flask import flash, redirect, url_for
flash('Access denied. You have not conducted inspections at this facility.', 'danger')
return redirect(url_for('reports.index'))
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'))
from app.utils.sla import sla_status, SLA_HOURS
from datetime import timedelta
# ── 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'
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)
# ── 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()
recent_inspections = (
Inspection.query
.filter_by(inspector_id=current_user.id)
.order_by(Inspection.inspection_date.desc())
.limit(5)
.all()
)
# Pending verification count
pending_verification = Issue.query.join(Area).filter(
Area.facility_id == facility_id,
Issue.status == 'pending_verification',
).count()
open_issues = Issue.query.filter_by(
assigned_to=current_user.id, status='open'
).count() if hasattr(Issue, 'assigned_to') else 0
# 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(
'auth/profile.html',
form=form,
total_inspections=total_inspections,
completed_inspections=completed_inspections,
recent_inspections=recent_inspections,
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 ────────────────────────────────────────────────────────────────
@bp.route('/users')
@bp.route('/export/inspections')
@login_required
@supervisor_required
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)
def export_inspections():
start, end = _date_range()
@bp.route('/users/new', methods=['GET', 'POST'])
@login_required
@supervisor_required
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(
'AUTH | user_%s | admin_id=%s admin=%s target_user=%s',
action_label, current_user.id, current_user.username, user.username,
'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_UPDATE, 'User', user.id, user.username,
f'account {action_label} by {current_user.username}',
ACTION_EXPORT, 'Inspection', None, 'CSV Export',
f'range={start.strftime("%Y-%m-%d")} to {end.strftime("%Y-%m-%d")}',
)
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 ───────────────────────────────────────────────────────
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()
@bp.route('/notification-matrix', methods=['GET', 'POST'])
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}"'}
)
@bp.route('/export/issues')
@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,
@supervisor_required
def export_issues():
start, end = _date_range()
logger.info(
'REPORTS | export_issues | user=%s | range=%s to %s',
current_user.username,
start.strftime('%Y-%m-%d'),
end.strftime('%Y-%m-%d'),
)
log_action(
ACTION_EXPORT, 'Issue', None, 'CSV Export',
f'range={start.strftime("%Y-%m-%d")} to {end.strftime("%Y-%m-%d")}',
)
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)
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 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}'))
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()
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'))
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()
# 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,
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}"'}
)
+6 -1
View File
@@ -56,7 +56,12 @@ class UserForm(FlaskForm):
('inspector', 'Inspector'),
('project_manager', 'Project Manager'),
# 'customer' is intentionally excluded — customer accounts are managed via /customers
], validators=[DataRequired()])
], validators=[Optional()])
# NOTE: Optional() here because directors submit no role value (the field is
# hidden in user_form.html for them). Role enforcement is handled in the
# route: directors always keep/default to 'inspector'; only admins may set
# an arbitrary role. DataRequired() would cause validate_on_submit() to
# fail silently for directors, preventing any save at all.
def __init__(self, user=None, *args, **kwargs):
super().__init__(*args, **kwargs)