Updated functionalities

This commit is contained in:
2026-04-25 12:11:05 -04:00
parent 5a1c252607
commit a515df6a02
26 changed files with 417 additions and 107 deletions
+4 -2
View File
@@ -1,6 +1,6 @@
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from flask import Blueprint, render_template, request, redirect, url_for, flash from flask import Blueprint, render_template, request, redirect, url_for, flash, abort
from flask_login import login_required from flask_login import login_required
from app import db from app import db
from app.models.audit import AuditLog from app.models.audit import AuditLog
@@ -88,7 +88,9 @@ def index():
@login_required @login_required
@admin_required @admin_required
def view(log_id): def view(log_id):
entry = AuditLog.query.get_or_404(log_id) entry = db.session.get(AuditLog, log_id)
if entry is None:
abort(404)
return render_template('audit/view.html', entry=entry) return render_template('audit/view.html', entry=entry)
+9 -3
View File
@@ -171,7 +171,9 @@ def create_user():
@login_required @login_required
@supervisor_required @supervisor_required
def edit_user(user_id): def edit_user(user_id):
user = User.query.get_or_404(user_id) user = db.session.get(User, user_id)
if user is None:
abort(404)
form = UserForm(user=user, obj=user) form = UserForm(user=user, obj=user)
if form.validate_on_submit(): if form.validate_on_submit():
@@ -198,7 +200,9 @@ def edit_user(user_id):
@login_required @login_required
@supervisor_required @supervisor_required
def delete_user(user_id): def delete_user(user_id):
user = User.query.get_or_404(user_id) user = db.session.get(User, user_id)
if user is None:
abort(404)
if user.id == current_user.id: if user.id == current_user.id:
flash('Cannot delete your own account.', 'danger') flash('Cannot delete your own account.', 'danger')
@@ -229,7 +233,9 @@ def delete_user(user_id):
@login_required @login_required
@supervisor_required @supervisor_required
def toggle_active(user_id): def toggle_active(user_id):
user = User.query.get_or_404(user_id) user = db.session.get(User, user_id)
if user is None:
abort(404)
if user.id == current_user.id: if user.id == current_user.id:
flash('You cannot disable your own account.', 'danger') flash('You cannot disable your own account.', 'danger')
+25 -9
View File
@@ -13,7 +13,7 @@ Provides a single screen to:
""" """
import logging import logging
from flask import Blueprint, render_template, redirect, url_for, flash, request from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import db from app import db
from app.models.user import User from app.models.user import User
@@ -238,7 +238,9 @@ def _send_invite_email(user, token):
@supervisor_required @supervisor_required
def resend_invite(customer_id): def resend_invite(customer_id):
"""Generate a fresh token and resend the set-password invitation email.""" """Generate a fresh token and resend the set-password invitation email."""
customer = User.query.get_or_404(customer_id) customer = db.session.get(User, customer_id)
if customer is None:
abort(404)
if customer.role != 'customer': if customer.role != 'customer':
flash('This action is only for customer accounts.', 'warning') flash('This action is only for customer accounts.', 'warning')
return redirect(url_for('customers.index')) return redirect(url_for('customers.index'))
@@ -297,7 +299,9 @@ def set_password(token):
@login_required @login_required
@supervisor_required @supervisor_required
def edit(customer_id): def edit(customer_id):
customer = User.query.get_or_404(customer_id) customer = db.session.get(User, customer_id)
if customer is None:
abort(404)
if customer.role != 'customer': if customer.role != 'customer':
flash('This page is only for customer accounts.', 'warning') flash('This page is only for customer accounts.', 'warning')
return redirect(url_for('customers.index')) return redirect(url_for('customers.index'))
@@ -329,7 +333,9 @@ def edit(customer_id):
@supervisor_required @supervisor_required
def manage(customer_id): def manage(customer_id):
"""Single-customer detail page: profile + all assignments.""" """Single-customer detail page: profile + all assignments."""
customer = User.query.get_or_404(customer_id) customer = db.session.get(User, customer_id)
if customer is None:
abort(404)
if customer.role != 'customer': if customer.role != 'customer':
flash('This page is only for customer accounts.', 'warning') flash('This page is only for customer accounts.', 'warning')
return redirect(url_for('customers.index')) return redirect(url_for('customers.index'))
@@ -365,7 +371,9 @@ def manage(customer_id):
@login_required @login_required
@supervisor_required @supervisor_required
def add_assignment(customer_id): def add_assignment(customer_id):
customer = User.query.get_or_404(customer_id) customer = db.session.get(User, customer_id)
if customer is None:
abort(404)
if customer.role != 'customer': if customer.role != 'customer':
flash('Assignments are only for customer accounts.', 'warning') flash('Assignments are only for customer accounts.', 'warning')
return redirect(url_for('customers.index')) return redirect(url_for('customers.index'))
@@ -377,7 +385,9 @@ def add_assignment(customer_id):
flash('Please select a contract.', 'warning') flash('Please select a contract.', 'warning')
return redirect(url_for('customers.manage', customer_id=customer_id)) return redirect(url_for('customers.manage', customer_id=customer_id))
project = Project.query.get_or_404(project_id) project = db.session.get(Project, project_id)
if project is None:
abort(404)
# Guard: duplicate assignment # Guard: duplicate assignment
existing = CustomerAssignment.query.filter_by( existing = CustomerAssignment.query.filter_by(
@@ -414,7 +424,9 @@ def add_assignment(customer_id):
@login_required @login_required
@supervisor_required @supervisor_required
def remove_assignment(assignment_id): def remove_assignment(assignment_id):
assignment = CustomerAssignment.query.get_or_404(assignment_id) assignment = db.session.get(CustomerAssignment, assignment_id)
if assignment is None:
abort(404)
customer_id = assignment.user_id customer_id = assignment.user_id
customer = db.session.get(User, customer_id) customer = db.session.get(User, customer_id)
project = db.session.get(Project, assignment.project_id) project = db.session.get(Project, assignment.project_id)
@@ -439,7 +451,9 @@ def remove_assignment(assignment_id):
@login_required @login_required
@supervisor_required @supervisor_required
def toggle_active(customer_id): def toggle_active(customer_id):
customer = User.query.get_or_404(customer_id) customer = db.session.get(User, customer_id)
if customer is None:
abort(404)
if customer.role != 'customer': if customer.role != 'customer':
flash('This action is only for customer accounts.', 'warning') flash('This action is only for customer accounts.', 'warning')
return redirect(url_for('customers.index')) return redirect(url_for('customers.index'))
@@ -747,6 +761,8 @@ def bulk_import():
@supervisor_required @supervisor_required
def facilities_for_project(project_id): def facilities_for_project(project_id):
from flask import jsonify from flask import jsonify
project = Project.query.get_or_404(project_id) project = db.session.get(Project, project_id)
if project is None:
abort(404)
facilities = project.facilities.filter_by(active=True).order_by(Facility.name).all() facilities = project.facilities.filter_by(active=True).order_by(Facility.name).all()
return jsonify([{'id': f.id, 'name': f.name} for f in facilities]) return jsonify([{'id': f.id, 'name': f.name} for f in facilities])
+10
View File
@@ -68,6 +68,15 @@ def index():
).filter(Area.facility_id.in_(customer_facility_ids)) ).filter(Area.facility_id.in_(customer_facility_ids))
open_issues = open_issues_q.count() open_issues = open_issues_q.count()
# Severity breakdown for the open issues card
open_issues_all = open_issues_q.all()
severity_breakdown = {
'critical': sum(1 for i in open_issues_all if i.severity == 'critical'),
'high': sum(1 for i in open_issues_all if i.severity == 'high'),
'medium': sum(1 for i in open_issues_all if i.severity == 'medium'),
'low': sum(1 for i in open_issues_all if i.severity == 'low'),
}
# ── Average score (last 30 days) ─────────────────────────────────────── # ── Average score (last 30 days) ───────────────────────────────────────
score_q = db.session.query(func.avg(Inspection.overall_score)).filter( score_q = db.session.query(func.avg(Inspection.overall_score)).filter(
Inspection.status == 'completed', Inspection.status == 'completed',
@@ -195,6 +204,7 @@ def index():
today_inspections = today_inspections, today_inspections = today_inspections,
completed_today = completed_today, completed_today = completed_today,
open_issues = open_issues, open_issues = open_issues,
severity_breakdown = severity_breakdown,
avg_score = round(avg_score, 2) if avg_score else None, avg_score = round(avg_score, 2) if avg_score else None,
recent_inspections = recent_inspections, recent_inspections = recent_inspections,
total_facilities = total_facilities, total_facilities = total_facilities,
+19 -7
View File
@@ -1,4 +1,4 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import db from app import db
from app.models.facility import Facility, Area from app.models.facility import Facility, Area
@@ -53,7 +53,9 @@ def create_facility():
@bp.route('/<int:facility_id>') @bp.route('/<int:facility_id>')
@login_required @login_required
def view_facility(facility_id): def view_facility(facility_id):
facility = Facility.query.get_or_404(facility_id) facility = db.session.get(Facility, facility_id)
if facility is None:
abort(404)
if current_user.role == 'customer': if current_user.role == 'customer':
cids = get_customer_scope(current_user) or [] cids = get_customer_scope(current_user) or []
if facility_id not in cids: if facility_id not in cids:
@@ -66,7 +68,9 @@ def view_facility(facility_id):
@login_required @login_required
@supervisor_required @supervisor_required
def edit_facility(facility_id): def edit_facility(facility_id):
facility = Facility.query.get_or_404(facility_id) facility = db.session.get(Facility, facility_id)
if facility is None:
abort(404)
form = FacilityForm(obj=facility) form = FacilityForm(obj=facility)
projects = Project.query.filter_by(active=True).order_by(Project.name).all() projects = Project.query.filter_by(active=True).order_by(Project.name).all()
form.project_id.choices = [(0, '— None —')] + [(p.id, p.name) for p in projects] form.project_id.choices = [(0, '— None —')] + [(p.id, p.name) for p in projects]
@@ -92,7 +96,9 @@ def edit_facility(facility_id):
@login_required @login_required
@admin_required @admin_required
def delete_facility(facility_id): def delete_facility(facility_id):
facility = Facility.query.get_or_404(facility_id) facility = db.session.get(Facility, facility_id)
if facility is None:
abort(404)
if facility.inspections.count() > 0: if facility.inspections.count() > 0:
flash(f'Cannot delete "{facility.name}" — it has existing inspection records.', 'danger') flash(f'Cannot delete "{facility.name}" — it has existing inspection records.', 'danger')
@@ -112,7 +118,9 @@ def delete_facility(facility_id):
@login_required @login_required
@supervisor_required @supervisor_required
def create_area(facility_id): def create_area(facility_id):
facility = Facility.query.get_or_404(facility_id) facility = db.session.get(Facility, facility_id)
if facility is None:
abort(404)
form = AreaForm() form = AreaForm()
form.facility_id.choices = [(facility.id, facility.name)] form.facility_id.choices = [(facility.id, facility.name)]
form.facility_id.data = facility.id form.facility_id.data = facility.id
@@ -137,7 +145,9 @@ def create_area(facility_id):
@login_required @login_required
@supervisor_required @supervisor_required
def edit_area(area_id): def edit_area(area_id):
area = Area.query.get_or_404(area_id) area = db.session.get(Area, area_id)
if area is None:
abort(404)
form = AreaForm(obj=area) form = AreaForm(obj=area)
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all() facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
@@ -160,7 +170,9 @@ def edit_area(area_id):
@login_required @login_required
@supervisor_required @supervisor_required
def delete_area(area_id): def delete_area(area_id):
area = Area.query.get_or_404(area_id) area = db.session.get(Area, area_id)
if area is None:
abort(404)
facility_id = area.facility_id facility_id = area.facility_id
if area.inspections.count() > 0: if area.inspections.count() > 0:
+33 -12
View File
@@ -4,7 +4,7 @@ import uuid
from datetime import datetime from datetime import datetime
from app.utils.time_utils import now_eastern from app.utils.time_utils import now_eastern
from flask import (Blueprint, render_template, redirect, url_for, from flask import (Blueprint, render_template, redirect, url_for,
flash, request, current_app, jsonify, Response) flash, request, current_app, jsonify, Response, abort)
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import db from app import db
from app.models.inspection import (Inspection, InspectionTemplate, from app.models.inspection import (Inspection, InspectionTemplate,
@@ -218,7 +218,8 @@ def index():
facilities=facilities, facilities=facilities,
status_filter=status_filter, status_filter=status_filter,
facility_filter=facility_filter, facility_filter=facility_filter,
follow_up_filter=follow_up_filter) follow_up_filter=follow_up_filter,
now=now_eastern())
# ── Start ───────────────────────────────────────────────────────────────────── # ── Start ─────────────────────────────────────────────────────────────────────
@@ -246,7 +247,9 @@ def start():
form.area_id.choices = [(0, '— No specific area —')] + [(a.id, a.name) for a in areas] form.area_id.choices = [(0, '— No specific area —')] + [(a.id, a.name) for a in areas]
if form.validate_on_submit(): if form.validate_on_submit():
template = InspectionTemplate.query.get_or_404(form.template_id.data) template = db.session.get(InspectionTemplate, form.template_id.data)
if template is None:
abort(404)
if not template.get_form_schema(): if not template.get_form_schema():
flash('This template has no form fields yet. Please build the form in the template editor first.', 'warning') flash('This template has no form fields yet. Please build the form in the template editor first.', 'warning')
@@ -290,7 +293,9 @@ def areas_for_facility(facility_id):
@bp.route('/<int:inspection_id>/execute', methods=['GET', 'POST']) @bp.route('/<int:inspection_id>/execute', methods=['GET', 'POST'])
@login_required @login_required
def execute(inspection_id): def execute(inspection_id):
inspection = Inspection.query.get_or_404(inspection_id) inspection = db.session.get(Inspection, inspection_id)
if inspection is None:
abort(404)
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id: if current_user.role == 'inspector' and inspection.inspector_id != current_user.id:
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
@@ -430,7 +435,9 @@ def _save_draft(inspection, responses):
@bp.route('/<int:inspection_id>/save-draft', methods=['POST']) @bp.route('/<int:inspection_id>/save-draft', methods=['POST'])
@login_required @login_required
def save_draft_ajax(inspection_id): def save_draft_ajax(inspection_id):
inspection = Inspection.query.get_or_404(inspection_id) inspection = db.session.get(Inspection, inspection_id)
if inspection is None:
abort(404)
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id: if current_user.role == 'inspector' and inspection.inspector_id != current_user.id:
return jsonify({'ok': False, 'error': 'Access denied'}), 403 return jsonify({'ok': False, 'error': 'Access denied'}), 403
@@ -466,7 +473,9 @@ def save_draft_ajax(inspection_id):
@bp.route('/<int:inspection_id>') @bp.route('/<int:inspection_id>')
@login_required @login_required
def view(inspection_id): def view(inspection_id):
inspection = Inspection.query.get_or_404(inspection_id) inspection = db.session.get(Inspection, inspection_id)
if inspection is None:
abort(404)
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id: if current_user.role == 'inspector' and inspection.inspector_id != current_user.id:
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
@@ -665,7 +674,9 @@ def view(inspection_id):
@bp.route('/<int:inspection_id>/flag-issue', methods=['GET', 'POST']) @bp.route('/<int:inspection_id>/flag-issue', methods=['GET', 'POST'])
@login_required @login_required
def flag_issue(inspection_id): def flag_issue(inspection_id):
inspection = Inspection.query.get_or_404(inspection_id) inspection = db.session.get(Inspection, inspection_id)
if inspection is None:
abort(404)
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id: if current_user.role == 'inspector' and inspection.inspector_id != current_user.id:
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
@@ -751,7 +762,9 @@ def flag_issue(inspection_id):
@login_required @login_required
def export_pdf(inspection_id): def export_pdf(inspection_id):
"""Generate and stream a PDF report for the given inspection.""" """Generate and stream a PDF report for the given inspection."""
inspection = Inspection.query.get_or_404(inspection_id) inspection = db.session.get(Inspection, inspection_id)
if inspection is None:
abort(404)
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id: if current_user.role == 'inspector' and inspection.inspector_id != current_user.id:
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
@@ -821,7 +834,9 @@ def export_pdf(inspection_id):
@supervisor_required @supervisor_required
def flag_followup(inspection_id): def flag_followup(inspection_id):
"""Mark an inspection as requiring a follow-up re-inspection.""" """Mark an inspection as requiring a follow-up re-inspection."""
inspection = Inspection.query.get_or_404(inspection_id) inspection = db.session.get(Inspection, inspection_id)
if inspection is None:
abort(404)
note = request.form.get('follow_up_note', '').strip() or None note = request.form.get('follow_up_note', '').strip() or None
inspection.follow_up_required = True inspection.follow_up_required = True
@@ -844,7 +859,9 @@ def flag_followup(inspection_id):
@supervisor_required @supervisor_required
def clear_followup(inspection_id): def clear_followup(inspection_id):
"""Clear the follow-up required flag once actioned.""" """Clear the follow-up required flag once actioned."""
inspection = Inspection.query.get_or_404(inspection_id) inspection = db.session.get(Inspection, inspection_id)
if inspection is None:
abort(404)
inspection.follow_up_required = False inspection.follow_up_required = False
inspection.follow_up_note = None inspection.follow_up_note = None
db.session.commit() db.session.commit()
@@ -863,7 +880,9 @@ def reinspect(inspection_id):
"""Pre-fill the Start Inspection form with the same template/facility, """Pre-fill the Start Inspection form with the same template/facility,
linking the new inspection to the parent via parent_inspection_id.""" linking the new inspection to the parent via parent_inspection_id."""
from flask import session from flask import session
parent = Inspection.query.get_or_404(inspection_id) parent = db.session.get(Inspection, inspection_id)
if parent is None:
abort(404)
if current_user.role == 'customer': if current_user.role == 'customer':
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
@@ -886,7 +905,9 @@ def reinspect(inspection_id):
@login_required @login_required
@supervisor_required @supervisor_required
def delete(inspection_id): def delete(inspection_id):
inspection = Inspection.query.get_or_404(inspection_id) inspection = db.session.get(Inspection, inspection_id)
if inspection is None:
abort(404)
insp_id = inspection.id insp_id = inspection.id
insp_date = inspection.inspection_date.strftime('%Y-%m-%d %H:%M') insp_date = inspection.inspection_date.strftime('%Y-%m-%d %H:%M')
+98 -7
View File
@@ -1,7 +1,8 @@
import os import os
import logging
from app.utils.time_utils import now_eastern from app.utils.time_utils import now_eastern
from flask import (Blueprint, render_template, redirect, url_for, from flask import (Blueprint, render_template, redirect, url_for,
flash, request, current_app, jsonify) flash, request, current_app, jsonify, abort)
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import db from app import db
from app.models.issue import Issue, IssueComment, IssueFollower from app.models.issue import Issue, IssueComment, IssueFollower
@@ -21,6 +22,8 @@ from app.utils.sla import sla_status
bp = Blueprint('issues', __name__, url_prefix='/issues') bp = Blueprint('issues', __name__, url_prefix='/issues')
logger = logging.getLogger(__name__)
# ── Shared helper ───────────────────────────────────────────────────────────── # ── Shared helper ─────────────────────────────────────────────────────────────
@@ -65,10 +68,16 @@ def index():
severity_filter = request.args.get('severity', '') severity_filter = request.args.get('severity', '')
status_filter = request.args.get('status', '') status_filter = request.args.get('status', '')
sla_filter = request.args.get('sla', '') sla_filter = request.args.get('sla', '')
facility_filter = request.args.get('facility_id', '')
if severity_filter: if severity_filter:
q = q.filter(Issue.severity == severity_filter) q = q.filter(Issue.severity == severity_filter)
if status_filter: if status_filter:
q = q.filter(Issue.status == status_filter) q = q.filter(Issue.status == status_filter)
if facility_filter:
q = q.join(Area, Issue.area_id == Area.id, isouter=True).filter(
Area.facility_id == int(facility_filter)
)
# SLA filter — applied in Python after DB query since SLA is computed # SLA filter — applied in Python after DB query since SLA is computed
issues_paged = q.paginate(page=page, per_page=25, error_out=False) issues_paged = q.paginate(page=page, per_page=25, error_out=False)
@@ -86,12 +95,29 @@ def index():
for f in IssueFollower.query.filter_by(user_id=current_user.id).all() for f in IssueFollower.query.filter_by(user_id=current_user.id).all()
} }
# Facilities for the filter dropdown — scoped for customers, full list otherwise
if current_user.role == 'customer':
facility_ids = get_customer_scope(current_user) or []
facilities = Facility.query.filter(
Facility.id.in_(facility_ids), Facility.active == True
).order_by(Facility.name).all()
else:
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
# Staff for quick-assign dropdown — same roles as the full issue form
staff = User.query.filter(
User.role.in_(['admin', 'director', 'inspector']), User.active == True
).order_by(User.username).all()
return render_template('issues/list.html', return render_template('issues/list.html',
issues=issues_paged, issues=issues_paged,
issue_items=filtered_items, issue_items=filtered_items,
severity_filter=severity_filter, severity_filter=severity_filter,
status_filter=status_filter, status_filter=status_filter,
sla_filter=sla_filter, sla_filter=sla_filter,
facility_filter=facility_filter,
facilities=facilities,
staff=staff,
followed_ids=followed_ids) followed_ids=followed_ids)
@@ -100,7 +126,9 @@ def index():
@bp.route('/<int:issue_id>', methods=['GET', 'POST']) @bp.route('/<int:issue_id>', methods=['GET', 'POST'])
@login_required @login_required
def view(issue_id): def view(issue_id):
issue = Issue.query.get_or_404(issue_id) issue = db.session.get(Issue, issue_id)
if issue is None:
abort(404)
if current_user.role == 'inspector' and issue.assigned_to != current_user.id: if current_user.role == 'inspector' and issue.assigned_to != current_user.id:
flash('Access denied. You can only view issues assigned to you.', 'danger') flash('Access denied. You can only view issues assigned to you.', 'danger')
@@ -306,7 +334,9 @@ def view(issue_id):
@bp.route('/<int:issue_id>/follow', methods=['POST']) @bp.route('/<int:issue_id>/follow', methods=['POST'])
@login_required @login_required
def follow(issue_id): def follow(issue_id):
issue = Issue.query.get_or_404(issue_id) issue = db.session.get(Issue, issue_id)
if issue is None:
abort(404)
if not issue.is_followed_by(current_user): if not issue.is_followed_by(current_user):
follower = IssueFollower(issue_id=issue.id, user_id=current_user.id) follower = IssueFollower(issue_id=issue.id, user_id=current_user.id)
db.session.add(follower) db.session.add(follower)
@@ -326,7 +356,9 @@ def follow(issue_id):
@bp.route('/<int:issue_id>/unfollow', methods=['POST']) @bp.route('/<int:issue_id>/unfollow', methods=['POST'])
@login_required @login_required
def unfollow(issue_id): def unfollow(issue_id):
issue = Issue.query.get_or_404(issue_id) issue = db.session.get(Issue, issue_id)
if issue is None:
abort(404)
follower = issue.followers.filter_by(user_id=current_user.id).first() follower = issue.followers.filter_by(user_id=current_user.id).first()
if follower: if follower:
db.session.delete(follower) db.session.delete(follower)
@@ -430,7 +462,9 @@ def create():
@supervisor_required @supervisor_required
def verify(issue_id): def verify(issue_id):
"""Supervisor sign-off: confirms resolution is satisfactory and closes the issue.""" """Supervisor sign-off: confirms resolution is satisfactory and closes the issue."""
issue = Issue.query.get_or_404(issue_id) issue = db.session.get(Issue, issue_id)
if issue is None:
abort(404)
if issue.status not in ('resolved', 'pending_verification'): if issue.status not in ('resolved', 'pending_verification'):
flash('Only resolved or pending-verification issues can be verified.', 'warning') flash('Only resolved or pending-verification issues can be verified.', 'warning')
@@ -461,7 +495,9 @@ def verify(issue_id):
@login_required @login_required
def request_verification(issue_id): def request_verification(issue_id):
"""Inspector/assignee marks the issue as pending director verification.""" """Inspector/assignee marks the issue as pending director verification."""
issue = Issue.query.get_or_404(issue_id) issue = db.session.get(Issue, issue_id)
if issue is None:
abort(404)
if current_user.role == 'customer': if current_user.role == 'customer':
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
@@ -559,7 +595,9 @@ def delete(issue_id):
Restricted to admin and director roles. The deletion is recorded in Restricted to admin and director roles. The deletion is recorded in
the audit log before the record is removed so there is always a trace. the audit log before the record is removed so there is always a trace.
""" """
issue = Issue.query.get_or_404(issue_id) issue = db.session.get(Issue, issue_id)
if issue is None:
abort(404)
# Snapshot fields needed for logging before deletion # Snapshot fields needed for logging before deletion
issue_id_snap = issue.id issue_id_snap = issue.id
@@ -598,3 +636,56 @@ def delete(issue_id):
flash(f'Issue #{issue_id_snap} has been permanently deleted.', 'success') flash(f'Issue #{issue_id_snap} has been permanently deleted.', 'success')
return redirect(url_for('issues.index')) return redirect(url_for('issues.index'))
# ── Quick-assign (AJAX) ───────────────────────────────────────────────────────
@bp.route('/<int:issue_id>/quick-assign', methods=['POST'])
@login_required
def quick_assign(issue_id):
"""Inline assignee update from the issues list — returns JSON."""
if current_user.role not in ('admin', 'director'):
return jsonify({'ok': False, 'error': 'Permission denied'}), 403
issue = db.session.get(Issue, issue_id)
if issue is None:
abort(404)
data = request.get_json(silent=True) or {}
new_user_id = data.get('user_id') # int or None (unassign)
old_assigned_to = issue.assigned_to
if new_user_id:
user = db.session.get(User, int(new_user_id))
if not user:
return jsonify({'ok': False, 'error': 'User not found'}), 404
issue.assigned_to = user.id
label = user.display_name
else:
issue.assigned_to = None
label = '— Unassigned —'
db.session.commit()
# Notify new assignee if changed
if new_user_id and old_assigned_to != issue.assigned_to:
from app.utils.notifications import notify
from app.models.notification import EVENT_ISSUE_ASSIGNED
notify(
recipient = user,
title = f'Issue #{issue.id} Assigned to You',
body = (f'You have been assigned Issue #{issue.id} '
f'({issue.severity} severity) by {current_user.display_name}.'),
link = url_for('issues.view', issue_id=issue.id),
issue_id = issue.id,
event_type = EVENT_ISSUE_ASSIGNED,
send_email = True,
)
log_action(ACTION_UPDATE, 'Issue', issue.id,
f'#{issue.id}',
f'quick-assign: assigned_to={label} by {current_user.username}')
logger.info('ISSUE QUICK-ASSIGN | issue_id=%s | assigned_to=%s | by=%s',
issue.id, label, current_user.username)
return jsonify({'ok': True, 'label': label})
+3 -1
View File
@@ -80,7 +80,9 @@ def index():
@bp.route('/<int:notif_id>/mark-read', methods=['POST']) @bp.route('/<int:notif_id>/mark-read', methods=['POST'])
@login_required @login_required
def mark_read(notif_id): def mark_read(notif_id):
notif = Notification.query.get_or_404(notif_id) notif = db.session.get(Notification, notif_id)
if notif is None:
abort(404)
if notif.user_id != current_user.id: if notif.user_id != current_user.id:
abort(403) abort(403)
notif.is_read = True notif.is_read = True
+19 -7
View File
@@ -10,7 +10,7 @@ Access matrix:
""" """
import logging import logging
from flask import Blueprint, render_template, redirect, url_for, flash, request from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import db from app import db
from app.models.project import Project, CustomerAssignment from app.models.project import Project, CustomerAssignment
@@ -72,7 +72,9 @@ def create():
@login_required @login_required
@project_manager_required @project_manager_required
def view(project_id): def view(project_id):
project = Project.query.get_or_404(project_id) project = db.session.get(Project, project_id)
if project is None:
abort(404)
facilities = project.facilities.order_by(Facility.name).all() facilities = project.facilities.order_by(Facility.name).all()
assignments = ( assignments = (
CustomerAssignment.query CustomerAssignment.query
@@ -95,7 +97,9 @@ def view(project_id):
@login_required @login_required
@supervisor_required @supervisor_required
def edit(project_id): def edit(project_id):
project = Project.query.get_or_404(project_id) project = db.session.get(Project, project_id)
if project is None:
abort(404)
form = ProjectForm(obj=project) form = ProjectForm(obj=project)
pm_users = User.query.filter_by(role='project_manager', active=True).order_by(User.username).all() pm_users = User.query.filter_by(role='project_manager', active=True).order_by(User.username).all()
form.project_manager_id.choices = [(0, '— None —')] + [(u.id, u.username) for u in pm_users] form.project_manager_id.choices = [(0, '— None —')] + [(u.id, u.username) for u in pm_users]
@@ -123,7 +127,9 @@ def edit(project_id):
@login_required @login_required
@admin_required @admin_required
def delete(project_id): def delete(project_id):
project = Project.query.get_or_404(project_id) project = db.session.get(Project, project_id)
if project is None:
abort(404)
if project.facilities.count() > 0: if project.facilities.count() > 0:
flash(f'Cannot delete "{project.name}" — it has linked facilities. ' flash(f'Cannot delete "{project.name}" — it has linked facilities. '
@@ -147,7 +153,9 @@ def delete(project_id):
@login_required @login_required
@admin_required @admin_required
def add_assignment(project_id): def add_assignment(project_id):
project = Project.query.get_or_404(project_id) project = db.session.get(Project, project_id)
if project is None:
abort(404)
form = CustomerAssignmentForm() form = CustomerAssignmentForm()
# Customer users only # Customer users only
@@ -205,9 +213,13 @@ def add_assignment(project_id):
@login_required @login_required
@admin_required @admin_required
def remove_assignment(assignment_id): def remove_assignment(assignment_id):
assignment = CustomerAssignment.query.get_or_404(assignment_id) assignment = db.session.get(CustomerAssignment, assignment_id)
if assignment is None:
abort(404)
project_id = assignment.project_id project_id = assignment.project_id
project = Project.query.get_or_404(project_id) project = db.session.get(Project, project_id)
if project is None:
abort(404)
user = db.session.get(User, assignment.user_id) user = db.session.get(User, assignment.user_id)
username = user.username if user else f'user_id={assignment.user_id}' username = user.username if user else f'user_id={assignment.user_id}'
+7 -3
View File
@@ -3,7 +3,7 @@ import io
from datetime import datetime, timedelta from datetime import datetime, timedelta
from app.utils.time_utils import now_eastern from app.utils.time_utils import now_eastern
from flask import (Blueprint, render_template, request, from flask import (Blueprint, render_template, request,
Response, stream_with_context) Response, stream_with_context, abort)
from flask_login import login_required, current_user from flask_login import login_required, current_user
from sqlalchemy import func from sqlalchemy import func
from app import db from app import db
@@ -183,7 +183,9 @@ def facility_report(facility_id):
from flask import flash, redirect, url_for from flask import flash, redirect, url_for
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('dashboard.index')) return redirect(url_for('dashboard.index'))
facility = Facility.query.get_or_404(facility_id) facility = db.session.get(Facility, facility_id)
if facility is None:
abort(404)
if current_user.role == 'customer': if current_user.role == 'customer':
cids = get_customer_scope(current_user) or [] cids = get_customer_scope(current_user) or []
if facility_id not in cids: if facility_id not in cids:
@@ -233,7 +235,9 @@ def facility_scorecard(facility_id):
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('dashboard.index')) return redirect(url_for('dashboard.index'))
facility = Facility.query.get_or_404(facility_id) facility = db.session.get(Facility, facility_id)
if facility is None:
abort(404)
if current_user.role == 'customer': if current_user.role == 'customer':
cids = get_customer_scope(current_user) or [] cids = get_customer_scope(current_user) or []
+16 -6
View File
@@ -27,7 +27,7 @@ import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from flask import (Blueprint, render_template, redirect, url_for, flash, from flask import (Blueprint, render_template, redirect, url_for, flash,
request, jsonify, current_app) request, jsonify, current_app, abort)
from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_mail import Message from flask_mail import Message
from sqlalchemy import func from sqlalchemy import func
@@ -317,7 +317,9 @@ def create():
@login_required @login_required
@supervisor_required @supervisor_required
def edit(report_id): def edit(report_id):
report = ScheduledReport.query.get_or_404(report_id) report = db.session.get(ScheduledReport, report_id)
if report is None:
abort(404)
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all() facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
if request.method == 'POST': if request.method == 'POST':
@@ -345,7 +347,9 @@ def edit(report_id):
@login_required @login_required
@supervisor_required @supervisor_required
def delete(report_id): def delete(report_id):
report = ScheduledReport.query.get_or_404(report_id) report = db.session.get(ScheduledReport, report_id)
if report is None:
abort(404)
name = report.name name = report.name
rid = report.id rid = report.id
db.session.delete(report) db.session.delete(report)
@@ -360,7 +364,9 @@ def delete(report_id):
@supervisor_required @supervisor_required
def preview(report_id): def preview(report_id):
"""Render the scheduled report email in-browser for review.""" """Render the scheduled report email in-browser for review."""
report = ScheduledReport.query.get_or_404(report_id) report = db.session.get(ScheduledReport, report_id)
if report is None:
abort(404)
start, end = _date_window(report.frequency) start, end = _date_window(report.frequency)
data = _build_report_data(report, start, end) data = _build_report_data(report, start, end)
base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/') base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/')
@@ -382,7 +388,9 @@ def preview_pdf(report_id):
from flask import Response from flask import Response
from app.utils.pdf_export import generate_scheduled_report_pdf from app.utils.pdf_export import generate_scheduled_report_pdf
report = ScheduledReport.query.get_or_404(report_id) report = db.session.get(ScheduledReport, report_id)
if report is None:
abort(404)
start, end = _date_window(report.frequency) start, end = _date_window(report.frequency)
data = _build_report_data(report, start, end) data = _build_report_data(report, start, end)
@@ -414,7 +422,9 @@ def preview_pdf(report_id):
@supervisor_required @supervisor_required
def send_now(report_id): def send_now(report_id):
"""Manually trigger a single report — useful for testing.""" """Manually trigger a single report — useful for testing."""
report = ScheduledReport.query.get_or_404(report_id) report = db.session.get(ScheduledReport, report_id)
if report is None:
abort(404)
ok = _send_report(report) ok = _send_report(report)
if ok: if ok:
report.last_sent_at = now_eastern() report.last_sent_at = now_eastern()
+37 -13
View File
@@ -1,4 +1,4 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, abort
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import db from app import db
from app.models.inspection import InspectionTemplate, ChecklistItem from app.models.inspection import InspectionTemplate, ChecklistItem
@@ -48,7 +48,9 @@ def create_template():
@bp.route('/<int:template_id>') @bp.route('/<int:template_id>')
@login_required @login_required
def view_template(template_id): def view_template(template_id):
template = InspectionTemplate.query.get_or_404(template_id) template = db.session.get(InspectionTemplate, template_id)
if template is None:
abort(404)
form_fields = template.get_form_schema() form_fields = template.get_form_schema()
return render_template( return render_template(
'templates/view.html', 'templates/view.html',
@@ -61,7 +63,9 @@ def view_template(template_id):
@login_required @login_required
@supervisor_required @supervisor_required
def edit_template(template_id): def edit_template(template_id):
template = InspectionTemplate.query.get_or_404(template_id) template = db.session.get(InspectionTemplate, template_id)
if template is None:
abort(404)
form = InspectionTemplateForm(obj=template) form = InspectionTemplateForm(obj=template)
if form.validate_on_submit(): if form.validate_on_submit():
@@ -87,7 +91,9 @@ def edit_template(template_id):
@login_required @login_required
@supervisor_required @supervisor_required
def rename_template(template_id): def rename_template(template_id):
template = InspectionTemplate.query.get_or_404(template_id) template = db.session.get(InspectionTemplate, template_id)
if template is None:
abort(404)
new_name = request.form.get('name', '').strip() new_name = request.form.get('name', '').strip()
if not new_name: if not new_name:
@@ -118,7 +124,9 @@ def rename_template(template_id):
@login_required @login_required
@supervisor_required @supervisor_required
def delete_template(template_id): def delete_template(template_id):
template = InspectionTemplate.query.get_or_404(template_id) template = db.session.get(InspectionTemplate, template_id)
if template is None:
abort(404)
if template.inspections.count() > 0: if template.inspections.count() > 0:
flash('Cannot delete template with existing inspections.', 'danger') flash('Cannot delete template with existing inspections.', 'danger')
@@ -138,7 +146,9 @@ def delete_template(template_id):
@login_required @login_required
@supervisor_required @supervisor_required
def duplicate_template(template_id): def duplicate_template(template_id):
src = InspectionTemplate.query.get_or_404(template_id) src = db.session.get(InspectionTemplate, template_id)
if src is None:
abort(404)
# Duplicate the template header # Duplicate the template header
new_tpl = InspectionTemplate( new_tpl = InspectionTemplate(
@@ -183,7 +193,9 @@ def duplicate_template(template_id):
@login_required @login_required
@supervisor_required @supervisor_required
def form_editor(template_id): def form_editor(template_id):
template = InspectionTemplate.query.get_or_404(template_id) template = db.session.get(InspectionTemplate, template_id)
if template is None:
abort(404)
form_schema = template.get_form_schema() form_schema = template.get_form_schema()
return render_template( return render_template(
'templates/form_editor.html', 'templates/form_editor.html',
@@ -197,7 +209,9 @@ def form_editor(template_id):
@supervisor_required @supervisor_required
def save_form_schema(template_id): def save_form_schema(template_id):
"""AJAX endpoint — receives the full form schema as JSON and persists it.""" """AJAX endpoint — receives the full form schema as JSON and persists it."""
template = InspectionTemplate.query.get_or_404(template_id) template = db.session.get(InspectionTemplate, template_id)
if template is None:
abort(404)
data = request.get_json(silent=True) data = request.get_json(silent=True)
if data is None: if data is None:
@@ -270,7 +284,9 @@ def save_form_schema(template_id):
@login_required @login_required
def form_preview(template_id): def form_preview(template_id):
"""Renders a read-only preview of the dynamic form.""" """Renders a read-only preview of the dynamic form."""
template = InspectionTemplate.query.get_or_404(template_id) template = db.session.get(InspectionTemplate, template_id)
if template is None:
abort(404)
form_fields = template.get_form_schema() form_fields = template.get_form_schema()
return render_template( return render_template(
'templates/form_preview.html', 'templates/form_preview.html',
@@ -287,7 +303,9 @@ def form_preview(template_id):
@login_required @login_required
@supervisor_required @supervisor_required
def create_checklist_item(template_id): def create_checklist_item(template_id):
template = InspectionTemplate.query.get_or_404(template_id) template = db.session.get(InspectionTemplate, template_id)
if template is None:
abort(404)
form = ChecklistItemForm() form = ChecklistItemForm()
if form.validate_on_submit(): if form.validate_on_submit():
@@ -324,7 +342,9 @@ def create_checklist_item(template_id):
@login_required @login_required
@supervisor_required @supervisor_required
def edit_checklist_item(item_id): def edit_checklist_item(item_id):
item = ChecklistItem.query.get_or_404(item_id) item = db.session.get(ChecklistItem, item_id)
if item is None:
abort(404)
form = ChecklistItemForm(obj=item) form = ChecklistItemForm(obj=item)
if form.validate_on_submit(): if form.validate_on_submit():
@@ -353,7 +373,9 @@ def edit_checklist_item(item_id):
@login_required @login_required
@supervisor_required @supervisor_required
def delete_checklist_item(item_id): def delete_checklist_item(item_id):
item = ChecklistItem.query.get_or_404(item_id) item = db.session.get(ChecklistItem, item_id)
if item is None:
abort(404)
template_id = item.template_id template_id = item.template_id
item_desc = item.item_description[:80] item_desc = item.item_description[:80]
item_id_snap = item.id item_id_snap = item.id
@@ -369,7 +391,9 @@ def delete_checklist_item(item_id):
@login_required @login_required
@supervisor_required @supervisor_required
def reorder_items(template_id): def reorder_items(template_id):
template = InspectionTemplate.query.get_or_404(template_id) template = db.session.get(InspectionTemplate, template_id)
if template is None:
abort(404)
item_order = request.json.get('item_order', []) item_order = request.json.get('item_order', [])
for index, item_id in enumerate(item_order): for index, item_id in enumerate(item_order):
+13 -13
View File
@@ -99,38 +99,38 @@
<div class="collapse navbar-collapse" id="navbarNav"> <div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto"> <ul class="navbar-nav me-auto">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('dashboard.index') }}">Dashboard</a> <a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('dashboard.') }}" href="{{ url_for('dashboard.index') }}">Dashboard</a>
</li> </li>
{% if current_user.role in ['admin', 'director', 'project_manager'] %} {% if current_user.role in ['admin', 'director', 'project_manager'] %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('projects.index') }}">Contracts</a> <a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}" href="{{ url_for('projects.index') }}">Contracts</a>
</li> </li>
{% endif %} {% endif %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('facilities.list_facilities') }}">Facilities</a> <a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('facilities.') }}" href="{{ url_for('facilities.list_facilities') }}">Facilities</a>
</li> </li>
{% if current_user.role in ['admin', 'director'] %} {% if current_user.role in ['admin', 'director'] %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('templates.index') }}">Templates</a> <a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('templates.') }}" href="{{ url_for('templates.index') }}">Templates</a>
</li> </li>
{% endif %} {% endif %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('inspections.index') }}">Inspections</a> <a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspections.') }}" href="{{ url_for('inspections.index') }}">Inspections</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('reports.index') }}">Reports</a> <a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('reports.') }}" href="{{ url_for('reports.index') }}">Reports</a>
</li> </li>
{% if current_user.role in ['admin', 'director'] %} {% if current_user.role in ['admin', 'director'] %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('scheduled_reports.index') }}">Scheduled Reports</a> <a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('scheduled_reports.') }}" href="{{ url_for('scheduled_reports.index') }}">Scheduled Reports</a>
</li> </li>
{% endif %} {% endif %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('issues.index') }}">Issues</a> <a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('issues.') and request.endpoint != 'issues.verification_queue' }}" href="{{ url_for('issues.index') }}">Issues</a>
</li> </li>
{% if current_user.role in ['admin', 'director'] %} {% if current_user.role in ['admin', 'director'] %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link d-flex align-items-center gap-1" <a class="nav-link d-flex align-items-center gap-1 {{ 'active' if request.endpoint == 'issues.verification_queue' }}"
href="{{ url_for('issues.verification_queue') }}"> href="{{ url_for('issues.verification_queue') }}">
Verify Verify
{% if pending_verification_count and pending_verification_count > 0 %} {% if pending_verification_count and pending_verification_count > 0 %}
@@ -144,18 +144,18 @@
{% endif %} {% endif %}
{% if current_user.role in ['admin', 'director'] %} {% if current_user.role in ['admin', 'director'] %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.list_users') }}">Users</a> <a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('auth.') and 'user' in request.endpoint }}" href="{{ url_for('auth.list_users') }}">Users</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('customers.index') }}">Customers</a> <a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('customers.') }}" href="{{ url_for('customers.index') }}">Customers</a>
</li> </li>
{% endif %} {% endif %}
{% if current_user.role == 'admin' %} {% if current_user.role == 'admin' %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('audit.index') }}">Audit Trail</a> <a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('audit.') }}" href="{{ url_for('audit.index') }}">Audit Trail</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.notification_matrix') }}"> <a class="nav-link {{ 'active' if request.endpoint == 'auth.notification_matrix' }}" href="{{ url_for('auth.notification_matrix') }}">
<i class="bi bi-grid-3x3-gap-fill"></i> Notif. Matrix <i class="bi bi-grid-3x3-gap-fill"></i> Notif. Matrix
</a> </a>
</li> </li>
+11 -3
View File
@@ -31,7 +31,7 @@
<div class="card text-white bg-success h-100"> <div class="card text-white bg-success h-100">
<div class="card-body d-flex justify-content-between align-items-center"> <div class="card-body d-flex justify-content-between align-items-center">
<div> <div>
<div class="small text-white-50 fw-semibold">Completed Today</div> <div class="small text-white-50 fw-semibold">Submitted Today</div>
<div class="fs-2 fw-bold">{{ completed_today }}</div> <div class="fs-2 fw-bold">{{ completed_today }}</div>
</div> </div>
<i class="bi bi-check-circle" style="font-size:2.5rem;opacity:.25;"></i> <i class="bi bi-check-circle" style="font-size:2.5rem;opacity:.25;"></i>
@@ -46,6 +46,14 @@
<div> <div>
<div class="small text-white-50 fw-semibold">Open Issues</div> <div class="small text-white-50 fw-semibold">Open Issues</div>
<div class="fs-2 fw-bold">{{ open_issues }}</div> <div class="fs-2 fw-bold">{{ open_issues }}</div>
{% if open_issues > 0 %}
<div class="mt-1" style="font-size:.7rem;line-height:1.6;">
{% if severity_breakdown.critical > 0 %}<span class="badge bg-danger me-1">{{ severity_breakdown.critical }} critical</span>{% endif %}
{% if severity_breakdown.high > 0 %}<span class="badge bg-danger me-1">{{ severity_breakdown.high }} high</span>{% endif %}
{% if severity_breakdown.medium > 0 %}<span class="badge bg-dark me-1">{{ severity_breakdown.medium }} med</span>{% endif %}
{% if severity_breakdown.low > 0 %}<span class="badge bg-secondary me-1">{{ severity_breakdown.low }} low</span>{% endif %}
</div>
{% endif %}
</div> </div>
<i class="bi bi-exclamation-triangle" style="font-size:2.5rem;opacity:.25;"></i> <i class="bi bi-exclamation-triangle" style="font-size:2.5rem;opacity:.25;"></i>
</div> </div>
@@ -327,7 +335,7 @@
<td><small>{{ insp.inspection_date.strftime('%Y-%m-%d %H:%M') }}</small></td> <td><small>{{ insp.inspection_date.strftime('%Y-%m-%d %H:%M') }}</small></td>
<td>{{ insp.facility.name }}</td> <td>{{ insp.facility.name }}</td>
<td>{{ insp.area.name if insp.area else '—' }}</td> <td>{{ insp.area.name if insp.area else '—' }}</td>
{% if current_user.role != 'inspector' %}<td>{{ insp.inspector.username }}</td>{% endif %} {% if current_user.role != 'inspector' %}<td>{{ insp.inspector.display_name }}</td>{% endif %}
<td> <td>
{% if insp.overall_score %} {% if insp.overall_score %}
<span class="badge bg-{% if insp.overall_score >= 90 %}success{% elif insp.overall_score >= 70 %}warning{% else %}danger{% endif %}"> <span class="badge bg-{% if insp.overall_score >= 90 %}success{% elif insp.overall_score >= 70 %}warning{% else %}danger{% endif %}">
@@ -339,7 +347,7 @@
</td> </td>
<td> <td>
<span class="badge bg-{% if insp.status == 'completed' %}success{% elif insp.status == 'flagged' %}danger{% else %}secondary{% endif %}"> <span class="badge bg-{% if insp.status == 'completed' %}success{% elif insp.status == 'flagged' %}danger{% else %}secondary{% endif %}">
{{ insp.status|title }} {{ 'Submitted' if insp.status == 'completed' else insp.status|title }}
</span> </span>
</td> </td>
</tr> </tr>
+1 -1
View File
@@ -209,7 +209,7 @@
<div class="sub"> <div class="sub">
{{ inspection.facility.name }} {{ inspection.facility.name }}
{% if inspection.area %} · {{ inspection.area.name }}{% endif %} {% if inspection.area %} · {{ inspection.area.name }}{% endif %}
&nbsp;·&nbsp; Inspector: <strong style="color:#e2e8f0;">{{ inspection.inspector.username }}</strong> &nbsp;·&nbsp; Inspector: <strong style="color:#e2e8f0;">{{ inspection.inspector.display_name }}</strong>
</div> </div>
</div> </div>
<div class="d-flex gap-2 align-items-center"> <div class="d-flex gap-2 align-items-center">
+10 -2
View File
@@ -19,7 +19,7 @@
<select name="status" class="form-select form-select-sm"> <select name="status" class="form-select form-select-sm">
<option value="">All Statuses</option> <option value="">All Statuses</option>
{% for s in ['in_progress','completed','flagged'] %} {% for s in ['in_progress','completed','flagged'] %}
<option value="{{ s }}" {% if status_filter == s %}selected{% endif %}>{{ s|replace('_',' ')|title }}</option> <option value="{{ s }}" {% if status_filter == s %}selected{% endif %}>{{ 'Submitted' if s == 'completed' else s|replace('_',' ')|title }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@@ -59,7 +59,7 @@
<td>{{ ins.facility.name }}</td> <td>{{ ins.facility.name }}</td>
<td>{% if ins.area %}{{ ins.area.name }}{% else %}<span class="text-muted"></span>{% endif %}</td> <td>{% if ins.area %}{{ ins.area.name }}{% else %}<span class="text-muted"></span>{% endif %}</td>
<td>{{ ins.template.name }}</td> <td>{{ ins.template.name }}</td>
<td>{{ ins.inspector.username }}</td> <td>{{ ins.inspector.display_name }}</td>
<td> <td>
{% if ins.overall_score %} {% if ins.overall_score %}
<span class="badge bg-{{ 'success' if ins.overall_score >= 90 else 'warning' if ins.overall_score >= 70 else 'danger' }}"> <span class="badge bg-{{ 'success' if ins.overall_score >= 90 else 'warning' if ins.overall_score >= 70 else 'danger' }}">
@@ -71,6 +71,14 @@
<span class="badge bg-{{ 'success' if ins.status == 'completed' else 'danger' if ins.status == 'flagged' else 'secondary' }}"> <span class="badge bg-{{ 'success' if ins.status == 'completed' else 'danger' if ins.status == 'flagged' else 'secondary' }}">
{{ 'Submitted' if ins.status == 'completed' else ins.status|replace('_',' ')|title }} {{ 'Submitted' if ins.status == 'completed' else ins.status|replace('_',' ')|title }}
</span> </span>
{% if ins.status == 'in_progress' %}
{% set hours_open = ((now - ins.inspection_date).total_seconds() / 3600) %}
{% if hours_open > 24 %}
<span class="badge bg-warning text-dark ms-1" title="In progress for over 24 hours — may be stale">
<i class="bi bi-clock-history"></i> Stale
</span>
{% endif %}
{% endif %}
{% if ins.follow_up_required and not ins.follow_ups.count() %} {% if ins.follow_up_required and not ins.follow_ups.count() %}
<span class="badge bg-danger ms-1" title="Follow-up re-inspection required"> <span class="badge bg-danger ms-1" title="Follow-up re-inspection required">
<i class="bi bi-arrow-repeat"></i> Follow-up <i class="bi bi-arrow-repeat"></i> Follow-up
+2 -2
View File
@@ -1,11 +1,11 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Start Inspection{% endblock %} {% block title %}New Inspection{% endblock %}
{% block content %} {% block content %}
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-lg-6"> <div class="col-lg-6">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-header bg-primary text-white"> <div class="card-header bg-primary text-white">
<h5 class="mb-0"><i class="bi bi-play-circle"></i> Start New Inspection</h5> <h5 class="mb-0"><i class="bi bi-play-circle"></i> New Inspection</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="post"> <form method="post">
+1 -1
View File
@@ -471,7 +471,7 @@
<h4><i class="bi bi-clipboard-check"></i> {{ inspection.template.name }}</h4> <h4><i class="bi bi-clipboard-check"></i> {{ inspection.template.name }}</h4>
<div class="sub"> <div class="sub">
{{ inspection.facility.name }}{% if inspection.area %} · {{ inspection.area.name }}{% endif %} {{ inspection.facility.name }}{% if inspection.area %} · {{ inspection.area.name }}{% endif %}
&nbsp;·&nbsp; Inspector: <strong style="color:#e2e8f0;">{{ inspection.inspector.username }}</strong> &nbsp;·&nbsp; Inspector: <strong style="color:#e2e8f0;">{{ inspection.inspector.display_name }}</strong>
</div> </div>
</div> </div>
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
+1 -1
View File
@@ -88,7 +88,7 @@
</td> </td>
<td>{{ sla_badge(issue) }}</td> <td>{{ sla_badge(issue) }}</td>
<td> <td>
{% if issue.assigned_user %}{{ issue.assigned_user.username }} {% if issue.assigned_user %}{{ issue.assigned_user.display_name }}
{% else %}<span class="text-muted"></span>{% endif %} {% else %}<span class="text-muted"></span>{% endif %}
</td> </td>
<td class="text-nowrap"> <td class="text-nowrap">
+2 -2
View File
@@ -42,7 +42,7 @@
{% endif %} {% endif %}
<dt class="col-sm-3">Assigned To</dt> <dt class="col-sm-3">Assigned To</dt>
<dd class="col-sm-9">{{ issue.assigned_user.username if issue.assigned_user else '— Unassigned —' }}</dd> <dd class="col-sm-9">{{ issue.assigned_user.display_name if issue.assigned_user else '— Unassigned —' }}</dd>
{% if issue.resolved_at %} {% if issue.resolved_at %}
<dt class="col-sm-3">Resolved</dt> <dt class="col-sm-3">Resolved</dt>
@@ -94,7 +94,7 @@
<li class="list-group-item"> <li class="list-group-item">
<div class="d-flex justify-content-between align-items-center mb-1"> <div class="d-flex justify-content-between align-items-center mb-1">
<span class="fw-semibold text-dark"> <span class="fw-semibold text-dark">
<i class="bi bi-person-circle"></i> {{ c.author.username }} <i class="bi bi-person-circle"></i> {{ c.author.display_name }}
</span> </span>
<span class="d-flex align-items-center gap-2"> <span class="d-flex align-items-center gap-2">
<span class="badge bg-{{ 'success' if c.status_at_time == 'resolved' else 'warning text-dark' if c.status_at_time == 'in_progress' else 'danger' }} rounded-pill" style="font-size:.65rem;"> <span class="badge bg-{{ 'success' if c.status_at_time == 'resolved' else 'warning text-dark' if c.status_at_time == 'in_progress' else 'danger' }} rounded-pill" style="font-size:.65rem;">
+89 -5
View File
@@ -13,7 +13,7 @@
<div class="card shadow-sm mb-4"> <div class="card shadow-sm mb-4">
<div class="card-body py-2"> <div class="card-body py-2">
<form method="get" class="row g-2 align-items-end"> <form method="get" class="row g-2 align-items-end">
<div class="col-md-3"> <div class="col-md-2">
<label class="form-label small mb-1">Severity</label> <label class="form-label small mb-1">Severity</label>
<select name="severity" class="form-select form-select-sm"> <select name="severity" class="form-select form-select-sm">
<option value="">All</option> <option value="">All</option>
@@ -22,7 +22,7 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="col-md-3"> <div class="col-md-2">
<label class="form-label small mb-1">Status</label> <label class="form-label small mb-1">Status</label>
<select name="status" class="form-select form-select-sm"> <select name="status" class="form-select form-select-sm">
<option value="">All</option> <option value="">All</option>
@@ -31,6 +31,15 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="col-md-3">
<label class="form-label small mb-1">Facility</label>
<select name="facility_id" class="form-select form-select-sm">
<option value="">All Facilities</option>
{% for f in facilities %}
<option value="{{ f.id }}" {{ 'selected' if facility_filter == f.id|string }}>{{ f.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-auto"> <div class="col-auto">
<button type="submit" class="btn btn-sm btn-outline-primary">Filter</button> <button type="submit" class="btn btn-sm btn-outline-primary">Filter</button>
<a href="{{ url_for('issues.index') }}" class="btn btn-sm btn-outline-secondary">Clear</a> <a href="{{ url_for('issues.index') }}" class="btn btn-sm btn-outline-secondary">Clear</a>
@@ -51,6 +60,7 @@
<th>Facility / Area</th> <th>Facility / Area</th>
<th>Description</th> <th>Description</th>
<th>Status</th> <th>Status</th>
<th>SLA</th>
<th>Assigned</th> <th>Assigned</th>
<th></th> <th></th>
</tr> </tr>
@@ -58,6 +68,7 @@
<tbody> <tbody>
{% for issue in issues.items %} {% for issue in issues.items %}
{% set is_following = issue.id in followed_ids %} {% set is_following = issue.id in followed_ids %}
{% set sla = sla_status(issue) %}
<tr> <tr>
<td><small>{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</small></td> <td><small>{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</small></td>
<td> <td>
@@ -76,8 +87,32 @@
</span> </span>
</td> </td>
<td> <td>
{% if issue.assigned_user %}{{ issue.assigned_user.username }} {% if sla == 'breached' %}
<span class="badge bg-danger" title="SLA deadline has passed"><i class="bi bi-alarm me-1"></i>Breached</span>
{% elif sla == 'at_risk' %}
{% set hrs = sla_hours_remaining(issue) %}
<span class="badge bg-warning text-dark" title="Over 75% of SLA window elapsed"><i class="bi bi-hourglass-split me-1"></i>{{ hrs|abs|round(1) }}h left</span>
{% elif sla == 'ok' %}
<span class="badge bg-secondary">OK</span>
{% else %}
<span class="text-muted small"></span>
{% endif %}
</td>
<td>
{% if current_user.role in ['admin', 'director'] and issue.status != 'resolved' %}
<div class="d-flex align-items-center gap-1 quick-assign-wrap" data-issue-id="{{ issue.id }}">
<select class="form-select form-select-sm quick-assign-select" style="min-width:110px;font-size:.78rem;">
<option value="">— Unassigned —</option>
{% for u in staff %}
<option value="{{ u.id }}" {{ 'selected' if issue.assigned_to == u.id }}>{{ u.display_name }}</option>
{% endfor %}
</select>
<span class="quick-assign-spinner spinner-border spinner-border-sm text-secondary d-none" role="status"></span>
</div>
{% else %}
{% if issue.assigned_user %}{{ issue.assigned_user.display_name }}
{% else %}<span class="text-muted"></span>{% endif %} {% else %}<span class="text-muted"></span>{% endif %}
{% endif %}
</td> </td>
<td class="text-nowrap"> <td class="text-nowrap">
{# Following badge + inline unfollow #} {# Following badge + inline unfollow #}
@@ -90,7 +125,7 @@
class="d-inline" class="d-inline"
title="Unfollow this issue"> title="Unfollow this issue">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ url_for('issues.index', page=issues.page, severity=severity_filter, status=status_filter) }}"> <input type="hidden" name="next" value="{{ url_for('issues.index', page=issues.page, severity=severity_filter, status=status_filter, facility_id=facility_filter) }}">
<button type="submit" class="btn btn-sm btn-outline-primary p-0 px-1 me-1" <button type="submit" class="btn btn-sm btn-outline-primary p-0 px-1 me-1"
title="Unfollow"> title="Unfollow">
<i class="bi bi-bell-slash" style="font-size:.75rem;"></i> <i class="bi bi-bell-slash" style="font-size:.75rem;"></i>
@@ -131,7 +166,7 @@
{% if p %} {% if p %}
<li class="page-item {{ 'active' if p == issues.page }}"> <li class="page-item {{ 'active' if p == issues.page }}">
<a class="page-link" <a class="page-link"
href="{{ url_for('issues.index', page=p, severity=severity_filter, status=status_filter) }}">{{ p }}</a> href="{{ url_for('issues.index', page=p, severity=severity_filter, status=status_filter, facility_id=facility_filter) }}">{{ p }}</a>
</li> </li>
{% else %}<li class="page-item disabled"><span class="page-link"></span></li>{% endif %} {% else %}<li class="page-item disabled"><span class="page-link"></span></li>{% endif %}
{% endfor %} {% endfor %}
@@ -144,4 +179,53 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endblock %}
{% block extra_js %}
{% if current_user.role in ['admin', 'director'] %}
<script>
(function () {
'use strict';
document.querySelectorAll('.quick-assign-select').forEach(function (sel) {
sel.dataset.previous = sel.value;
sel.addEventListener('change', function () {
const wrap = sel.closest('.quick-assign-wrap');
const issueId = wrap.dataset.issueId;
const spinner = wrap.querySelector('.quick-assign-spinner');
const userId = sel.value || null;
sel.disabled = true;
spinner.classList.remove('d-none');
fetch('/issues/' + issueId + '/quick-assign', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}',
},
body: JSON.stringify({ user_id: userId ? parseInt(userId) : null }),
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.ok) {
alert('Assignment failed: ' + (data.error || 'Unknown error'));
sel.value = sel.dataset.previous;
} else {
sel.dataset.previous = sel.value;
}
})
.catch(function () {
alert('Network error — assignment not saved.');
sel.value = sel.dataset.previous;
})
.finally(function () {
sel.disabled = false;
spinner.classList.add('d-none');
});
});
});
}());
</script>
{% endif %}
{% endblock %} {% endblock %}
+1 -1
View File
@@ -86,7 +86,7 @@
{# Requested by / assignee #} {# Requested by / assignee #}
<td class="align-middle small"> <td class="align-middle small">
{% if issue.assigned_user %} {% if issue.assigned_user %}
<i class="bi bi-person-circle text-muted me-1"></i>{{ issue.assigned_user.username }} <i class="bi bi-person-circle text-muted me-1"></i>{{ issue.assigned_user.display_name }}
{% else %} {% else %}
<span class="text-muted">— unassigned —</span> <span class="text-muted">— unassigned —</span>
{% endif %} {% endif %}
+3 -3
View File
@@ -31,7 +31,7 @@
{% endif %} {% endif %}
<dt class="col-sm-3">Assigned To</dt> <dt class="col-sm-3">Assigned To</dt>
<dd class="col-sm-9">{{ issue.assigned_user.username if issue.assigned_user else '— Unassigned —' }}</dd> <dd class="col-sm-9">{{ issue.assigned_user.display_name if issue.assigned_user else '— Unassigned —' }}</dd>
{% if issue.resolved_at %} {% if issue.resolved_at %}
<dt class="col-sm-3">Resolved</dt> <dt class="col-sm-3">Resolved</dt>
@@ -75,7 +75,7 @@
<hr> <hr>
<div class="alert alert-success py-2 mb-0"> <div class="alert alert-success py-2 mb-0">
<i class="bi bi-patch-check-fill me-1"></i> <i class="bi bi-patch-check-fill me-1"></i>
<strong>Verified</strong> by {{ issue.verifier.username if issue.verifier else 'unknown' }} <strong>Verified</strong> by {{ issue.verifier.display_name if issue.verifier else 'unknown' }}
on {{ issue.verified_at.strftime('%Y-%m-%d %H:%M') }}. on {{ issue.verified_at.strftime('%Y-%m-%d %H:%M') }}.
{% if issue.verification_note %}<br><span class="small">{{ issue.verification_note }}</span>{% endif %} {% if issue.verification_note %}<br><span class="small">{{ issue.verification_note }}</span>{% endif %}
</div> </div>
@@ -112,7 +112,7 @@
<li class="list-group-item"> <li class="list-group-item">
<div class="d-flex justify-content-between align-items-center mb-1"> <div class="d-flex justify-content-between align-items-center mb-1">
<span class="fw-semibold text-dark"> <span class="fw-semibold text-dark">
<i class="bi bi-person-circle"></i> {{ c.author.username }} <i class="bi bi-person-circle"></i> {{ c.author.display_name }}
</span> </span>
<span class="d-flex align-items-center gap-2"> <span class="d-flex align-items-center gap-2">
<span class="badge bg-{{ 'success' if c.status_at_time == 'resolved' else 'warning text-dark' if c.status_at_time == 'in_progress' else 'danger' }} rounded-pill" style="font-size:.65rem;"> <span class="badge bg-{{ 'success' if c.status_at_time == 'resolved' else 'warning text-dark' if c.status_at_time == 'in_progress' else 'danger' }} rounded-pill" style="font-size:.65rem;">
+1 -1
View File
@@ -43,7 +43,7 @@
</small> </small>
{% if project.project_manager %} {% if project.project_manager %}
<small class="text-muted"> <small class="text-muted">
<i class="bi bi-person-badge"></i> {{ project.project_manager.username }} <i class="bi bi-person-badge"></i> {{ project.project_manager.display_name }}
</small> </small>
{% endif %} {% endif %}
</div> </div>
+1 -1
View File
@@ -44,7 +44,7 @@
</dd> </dd>
<dt class="col-5 text-muted small">Contract Manager</dt> <dt class="col-5 text-muted small">Contract Manager</dt>
<dd class="col-7 small"> <dd class="col-7 small">
{{ project.project_manager.username if project.project_manager else '—' }} {{ project.project_manager.display_name if project.project_manager else '—' }}
</dd> </dd>
<dt class="col-5 text-muted small">Created</dt> <dt class="col-5 text-muted small">Created</dt>
<dd class="col-7 small">{{ project.created_at.strftime('%Y-%m-%d') }}</dd> <dd class="col-7 small">{{ project.created_at.strftime('%Y-%m-%d') }}</dd>
+1 -1
View File
@@ -84,7 +84,7 @@
<td><small>{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}</small></td> <td><small>{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}</small></td>
<td>{{ ins.area.name if ins.area else '—' }}</td> <td>{{ ins.area.name if ins.area else '—' }}</td>
<td>{{ ins.template.name }}</td> <td>{{ ins.template.name }}</td>
<td>{{ ins.inspector.username }}</td> <td>{{ ins.inspector.display_name }}</td>
<td> <td>
{% if ins.overall_score %} {% if ins.overall_score %}
<span class="badge bg-{{ 'success' if ins.overall_score >= 90 else 'warning text-dark' if ins.overall_score >= 70 else 'danger' }}"> <span class="badge bg-{{ 'success' if ins.overall_score >= 90 else 'warning text-dark' if ins.overall_score >= 70 else 'danger' }}">