From c2d63b61a3cf0287426cc27ccc6f0c1162a9d779 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Wed, 6 May 2026 12:14:46 -0400 Subject: [PATCH] 05/06 Modify Director's can NOT change user role, Inspector can view own reports 2 --- app/routes/reports.py | 788 ++++++++++++++++++++++++++---------------- app/utils/forms.py | 7 +- 2 files changed, 503 insertions(+), 292 deletions(-) diff --git a/app/routes/reports.py b/app/routes/reports.py index d4cdff4..f65a777 100644 --- a/app/routes/reports.py +++ b/app/routes/reports.py @@ -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/') @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//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() + + # Pending verification count + pending_verification = Issue.query.join(Area).filter( + Area.facility_id == facility_id, + Issue.status == 'pending_verification', ).count() - recent_inspections = ( - Inspection.query - .filter_by(inspector_id=current_user.id) - .order_by(Inspection.inspection_date.desc()) - .limit(5) - .all() + # 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, ) - open_issues = Issue.query.filter_by( - assigned_to=current_user.id, status='open' - ).count() if hasattr(Issue, 'assigned_to') else 0 +# ── CSV export ──────────────────────────────────────────────────────────────── - 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') +@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//edit', methods=['GET', 'POST']) -@login_required -@supervisor_required -def edit_user(user_id): - user = db.session.get(User, user_id) - if user is None: - abort(404) - form = UserForm(user=user, obj=user) - - # Directors may not change another user's role — that privilege is admin-only. - # The role field is removed from the form for directors so it cannot be - # submitted at all, and the existing role value is preserved on save. - director_editing = current_user.role == 'director' - - if form.validate_on_submit(): - user.username = form.username.data - user.full_name = form.full_name.data.strip() or None - user.email = form.email.data - - if not director_editing: - user.role = form.role.data - - if form.password.data: - user.set_password(form.password.data) - - db.session.commit() - logger.info('AUTH | user_edit | admin_id=%s admin=%s target_user_id=%s target_user=%s', - current_user.id, current_user.username, user.id, user.username) - log_action(ACTION_UPDATE, 'User', user.id, user.username, - f'role={user.role}; email={user.email}') - flash(f'User {user.username} updated successfully.', 'success') - return redirect(url_for('auth.list_users')) - - return render_template('auth/user_form.html', form=form, user=user, - title='Edit User', director_editing=director_editing) - - -@bp.route('/users//delete', methods=['POST']) -@login_required -@supervisor_required -def delete_user(user_id): - user = db.session.get(User, user_id) - if user is None: - abort(404) - - if user.id == current_user.id: - flash('Cannot delete your own account.', 'danger') - return redirect(url_for('auth.list_users')) - - # Guard: block deletion if user has related records that would orphan data - # or violate FK constraints. Issue.assigned_to and IssueComment.user_id carry - # no ondelete clause, so MySQL defaults to RESTRICT — the DELETE would fail at - # the DB level without these application-level checks and clear user-facing messages. - if user.inspections.count() > 0: - flash( - f'Cannot delete "{user.username}" — they have existing inspection records. ' - 'Deactivate the account instead.', - 'danger' - ) - return redirect(url_for('auth.list_users')) - - if user.assigned_issues.count() > 0: - flash( - f'Cannot delete "{user.username}" — they have issues assigned to them. ' - 'Reassign or resolve those issues first, then deactivate the account.', - 'danger' - ) - return redirect(url_for('auth.list_users')) - - from app.models.issue import IssueComment - if IssueComment.query.filter_by(user_id=user.id).count() > 0: - flash( - f'Cannot delete "{user.username}" — they have authored issue comments. ' - 'Deactivate the account instead.', - 'danger' - ) - return redirect(url_for('auth.list_users')) - - from app.models.inspection import InspectionTemplate - if InspectionTemplate.query.filter_by(created_by=user.id).count() > 0: - flash( - f'Cannot delete "{user.username}" — they have created inspection templates. ' - 'Deactivate the account instead.', - 'danger' - ) - return redirect(url_for('auth.list_users')) - - username = user.username - user_id = user.id - db.session.delete(user) - db.session.commit() - logger.info('AUTH | user_delete | admin_id=%s admin=%s deleted_user=%s', - current_user.id, current_user.username, username) - log_action(ACTION_DELETE, 'User', user_id, username) - flash(f'User {username} deleted successfully.', 'success') - return redirect(url_for('auth.list_users')) - -@bp.route('/users//toggle-active', methods=['POST']) -@login_required -@supervisor_required -def toggle_active(user_id): - user = db.session.get(User, user_id) - if user is None: - abort(404) - - if user.id == current_user.id: - flash('You cannot disable your own account.', 'danger') - return redirect(url_for('auth.list_users')) - - user.active = not user.active - db.session.commit() - - action_label = 'enabled' if user.active else 'disabled' logger.info( - '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}"'} ) \ No newline at end of file diff --git a/app/utils/forms.py b/app/utils/forms.py index 73bd1f5..92d1e94 100644 --- a/app/utils/forms.py +++ b/app/utils/forms.py @@ -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)