05/06 Modify Director's can NOT change user role, Inspector can view own reports 2
This commit is contained in:
+497
-291
@@ -1,334 +1,540 @@
|
|||||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
|
import csv
|
||||||
from flask_login import login_user, logout_user, login_required, current_user
|
import io
|
||||||
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 logging
|
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__)
|
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'])
|
# ── Overview dashboard ────────────────────────────────────────────────────────
|
||||||
@limiter.limit('20 per minute; 5 per second')
|
|
||||||
def login():
|
@bp.route('/')
|
||||||
if current_user.is_authenticated:
|
@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'))
|
return redirect(url_for('dashboard.index'))
|
||||||
|
|
||||||
form = LoginForm()
|
start, end = _date_range()
|
||||||
if form.validate_on_submit():
|
|
||||||
user = User.query.filter_by(username=form.username.data).first()
|
|
||||||
|
|
||||||
if user and user.check_password(form.password.data):
|
# Resolve scoping for customers (facility list) and inspectors (inspector_id)
|
||||||
if not user.active:
|
customer_facility_ids = get_customer_scope(current_user) # None = unrestricted
|
||||||
flash('Your account has been disabled. Please contact an administrator.', 'danger')
|
is_inspector = current_user.role == 'inspector'
|
||||||
return render_template('auth/login.html', form=form)
|
|
||||||
if not user.password_set:
|
# Pre-compute the set of inspection IDs conducted by this inspector.
|
||||||
flash(
|
# Used in _scope_issue to avoid adding a join to queries that already
|
||||||
'Your account password has not been set yet. '
|
# have their own joins (e.g. issue_severity, issue_status group-by queries).
|
||||||
'Please check your email for the account setup link.',
|
inspector_inspection_ids = []
|
||||||
'warning'
|
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)
|
return q
|
||||||
# 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 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
|
@login_required
|
||||||
def logout():
|
def facility_report(facility_id):
|
||||||
log_action(ACTION_LOGOUT, 'User', current_user.id, current_user.username)
|
if current_user.role not in ['admin', 'director', 'project_manager', 'inspector', 'customer']:
|
||||||
logout_user()
|
from flask import flash, redirect, url_for
|
||||||
flash('Successfully logged out.', 'success')
|
flash('Access denied.', 'danger')
|
||||||
return redirect(url_for('auth.login'))
|
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
|
@login_required
|
||||||
def profile():
|
def facility_scorecard(facility_id):
|
||||||
"""User profile page — view stats and update email/password."""
|
"""Comprehensive per-facility scorecard: score trend, SLA compliance,
|
||||||
from app.models.inspection import Inspection
|
issue breakdown by severity, inspection frequency."""
|
||||||
from app.models.issue import Issue
|
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():
|
if current_user.role == 'customer':
|
||||||
current_user.full_name = form.full_name.data.strip() or None
|
cids = get_customer_scope(current_user) or []
|
||||||
current_user.email = form.email.data
|
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:
|
if current_user.role == 'inspector':
|
||||||
current_user.set_password(form.new_password.data)
|
has_access = Inspection.query.filter_by(
|
||||||
logger.info('AUTH | profile_password_change | user_id=%s username=%s',
|
facility_id=facility_id,
|
||||||
current_user.id, current_user.username)
|
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()
|
from app.utils.sla import sla_status, SLA_HOURS
|
||||||
logger.info('AUTH | profile_update | user_id=%s username=%s email=%s',
|
from datetime import timedelta
|
||||||
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'))
|
|
||||||
|
|
||||||
# ── Activity stats ────────────────────────────────────────────────────
|
now = now_eastern()
|
||||||
total_inspections = Inspection.query.filter_by(inspector_id=current_user.id).count()
|
days = request.args.get('days', 90, type=int)
|
||||||
completed_inspections = Inspection.query.filter_by(
|
if days not in (30, 60, 90, 180, 365):
|
||||||
inspector_id=current_user.id, status='completed'
|
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()
|
||||||
|
|
||||||
|
# Pending verification count
|
||||||
|
pending_verification = Issue.query.join(Area).filter(
|
||||||
|
Area.facility_id == facility_id,
|
||||||
|
Issue.status == 'pending_verification',
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
recent_inspections = (
|
# Follow-up required inspections
|
||||||
Inspection.query
|
followup_required = Inspection.query.filter(
|
||||||
.filter_by(inspector_id=current_user.id)
|
Inspection.facility_id == facility_id,
|
||||||
.order_by(Inspection.inspection_date.desc())
|
Inspection.follow_up_required == True,
|
||||||
.limit(5)
|
).order_by(Inspection.inspection_date.desc()).limit(10).all()
|
||||||
.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,
|
||||||
)
|
)
|
||||||
|
|
||||||
open_issues = Issue.query.filter_by(
|
# ── CSV export ────────────────────────────────────────────────────────────────
|
||||||
assigned_to=current_user.id, status='open'
|
|
||||||
).count() if hasattr(Issue, 'assigned_to') else 0
|
|
||||||
|
|
||||||
return render_template(
|
@bp.route('/export/inspections')
|
||||||
'auth/profile.html',
|
|
||||||
form=form,
|
|
||||||
total_inspections=total_inspections,
|
|
||||||
completed_inspections=completed_inspections,
|
|
||||||
recent_inspections=recent_inspections,
|
|
||||||
open_issues=open_issues,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/users')
|
|
||||||
@login_required
|
@login_required
|
||||||
@supervisor_required
|
@supervisor_required
|
||||||
def list_users():
|
def export_inspections():
|
||||||
# Exclude customer accounts — those are managed exclusively via /customers
|
start, end = _date_range()
|
||||||
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('/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(
|
logger.info(
|
||||||
'AUTH | user_%s | admin_id=%s admin=%s target_user=%s',
|
'REPORTS | export_inspections | user=%s | range=%s to %s',
|
||||||
action_label, current_user.id, current_user.username, user.username,
|
current_user.username,
|
||||||
|
start.strftime('%Y-%m-%d'),
|
||||||
|
end.strftime('%Y-%m-%d'),
|
||||||
)
|
)
|
||||||
log_action(
|
log_action(
|
||||||
ACTION_UPDATE, 'User', user.id, user.username,
|
ACTION_EXPORT, 'Inspection', None, 'CSV Export',
|
||||||
f'account {action_label} by {current_user.username}',
|
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
|
@login_required
|
||||||
@admin_required
|
@supervisor_required
|
||||||
def notification_matrix():
|
def export_issues():
|
||||||
"""Admin-only notification matrix — controls who receives each event type."""
|
start, end = _date_range()
|
||||||
import json as _json
|
|
||||||
from app.models.notification_matrix import (
|
logger.info(
|
||||||
NotificationMatrix, MATRIX_EVENTS, MATRIX_ROLES, MATRIX_DEFAULTS,
|
'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':
|
rows = db.session.query(
|
||||||
for event_key in MATRIX_EVENTS:
|
Issue.id,
|
||||||
for role_key, _ in MATRIX_ROLES:
|
Issue.reported_at,
|
||||||
row = NotificationMatrix.query.filter_by(
|
Facility.name.label('facility'),
|
||||||
event_type=event_key, role_key=role_key
|
Area.name.label('area'),
|
||||||
).first()
|
Issue.severity,
|
||||||
if row is None:
|
Issue.description,
|
||||||
row = NotificationMatrix(event_type=event_key, role_key=role_key)
|
Issue.status,
|
||||||
db.session.add(row)
|
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':
|
def generate():
|
||||||
raw = request.form.get(f'custom_{event_key}', '').strip()
|
buf = io.StringIO()
|
||||||
# Parse comma-separated emails into a JSON list
|
writer = csv.writer(buf)
|
||||||
emails = [e.strip() for e in raw.split(',') if e.strip()]
|
writer.writerow(['ID','Reported At','Facility','Area','Severity',
|
||||||
row.custom_emails = _json.dumps(emails)
|
'Description','Status','Resolved At','Assigned To'])
|
||||||
row.enabled = bool(emails)
|
yield buf.getvalue(); buf.seek(0); buf.truncate()
|
||||||
else:
|
|
||||||
row.enabled = bool(request.form.get(f'matrix_{event_key}_{role_key}'))
|
|
||||||
|
|
||||||
db.session.commit()
|
for r in rows:
|
||||||
log_action(ACTION_UPDATE, 'NotificationMatrix', None,
|
writer.writerow([
|
||||||
'Notification Matrix', 'admin updated notification matrix')
|
r.id,
|
||||||
logger.info('NOTIFICATION MATRIX UPDATED | by=%s', current_user.username)
|
r.reported_at.strftime('%Y-%m-%d %H:%M') if r.reported_at else '',
|
||||||
flash('Notification matrix saved successfully.', 'success')
|
r.facility, r.area, r.severity,
|
||||||
return redirect(url_for('auth.notification_matrix'))
|
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}}
|
filename = f"issues_{start.strftime('%Y%m%d')}_{end.strftime('%Y%m%d')}.csv"
|
||||||
all_rows = NotificationMatrix.query.all()
|
return Response(
|
||||||
state = {} # event_key -> role_key -> row
|
stream_with_context(generate()),
|
||||||
for row in all_rows:
|
mimetype='text/csv',
|
||||||
state.setdefault(row.event_type, {})[row.role_key] = row
|
headers={'Content-Disposition': f'attachment; filename="{filename}"'}
|
||||||
|
|
||||||
return render_template(
|
|
||||||
'auth/notification_matrix.html',
|
|
||||||
matrix_events = MATRIX_EVENTS,
|
|
||||||
matrix_roles = MATRIX_ROLES,
|
|
||||||
defaults = MATRIX_DEFAULTS,
|
|
||||||
state = state,
|
|
||||||
)
|
)
|
||||||
+6
-1
@@ -56,7 +56,12 @@ class UserForm(FlaskForm):
|
|||||||
('inspector', 'Inspector'),
|
('inspector', 'Inspector'),
|
||||||
('project_manager', 'Project Manager'),
|
('project_manager', 'Project Manager'),
|
||||||
# 'customer' is intentionally excluded — customer accounts are managed via /customers
|
# '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):
|
def __init__(self, user=None, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|||||||
Reference in New Issue
Block a user