First commit
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, abort
|
||||
from flask_login import login_required
|
||||
from app import db
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.user import User
|
||||
from app.utils.decorators import admin_required
|
||||
from app.utils.audit import log_action, ACTION_DELETE
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
bp = Blueprint('audit', __name__, url_prefix='/audit')
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── List (paginated, filterable) ──────────────────────────────────────────────
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
@admin_required
|
||||
def index():
|
||||
page = request.args.get('page', 1, type=int)
|
||||
|
||||
# ── Filter params ─────────────────────────────────────────────────────
|
||||
filter_user = request.args.get('user_id', '', type=str)
|
||||
filter_action = request.args.get('action', '', type=str)
|
||||
filter_entity_type = request.args.get('entity_type', '', type=str)
|
||||
filter_date_from = request.args.get('date_from', '', type=str)
|
||||
filter_date_to = request.args.get('date_to', '', type=str)
|
||||
|
||||
q = AuditLog.query.order_by(AuditLog.created_at.desc())
|
||||
|
||||
if filter_user.isdigit():
|
||||
q = q.filter(AuditLog.user_id == int(filter_user))
|
||||
if filter_action:
|
||||
q = q.filter(AuditLog.action == filter_action)
|
||||
if filter_entity_type:
|
||||
q = q.filter(AuditLog.entity_type == filter_entity_type)
|
||||
if filter_date_from:
|
||||
try:
|
||||
from datetime import datetime
|
||||
q = q.filter(AuditLog.created_at >= datetime.strptime(filter_date_from, '%Y-%m-%d'))
|
||||
except ValueError:
|
||||
pass
|
||||
if filter_date_to:
|
||||
try:
|
||||
from datetime import datetime, timedelta
|
||||
# Include the full day_to by shifting to midnight of next day
|
||||
q = q.filter(AuditLog.created_at < datetime.strptime(filter_date_to, '%Y-%m-%d') + timedelta(days=1))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
logs = q.paginate(page=page, per_page=50, error_out=False)
|
||||
users = User.query.order_by(User.username).all()
|
||||
|
||||
# Distinct action and entity_type values for the filter dropdowns
|
||||
distinct_actions = (
|
||||
AuditLog.query.with_entities(AuditLog.action)
|
||||
.distinct()
|
||||
.order_by(AuditLog.action)
|
||||
.all()
|
||||
)
|
||||
distinct_entity_types = (
|
||||
AuditLog.query.with_entities(AuditLog.entity_type)
|
||||
.distinct()
|
||||
.order_by(AuditLog.entity_type)
|
||||
.all()
|
||||
)
|
||||
|
||||
return render_template(
|
||||
'audit/index.html',
|
||||
logs=logs,
|
||||
users=users,
|
||||
distinct_actions=[r[0] for r in distinct_actions],
|
||||
distinct_entity_types=[r[0] for r in distinct_entity_types],
|
||||
filter_user=filter_user,
|
||||
filter_action=filter_action,
|
||||
filter_entity_type=filter_entity_type,
|
||||
filter_date_from=filter_date_from,
|
||||
filter_date_to=filter_date_to,
|
||||
)
|
||||
|
||||
|
||||
# ── Detail ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:log_id>')
|
||||
@login_required
|
||||
@admin_required
|
||||
def view(log_id):
|
||||
entry = db.session.get(AuditLog, log_id)
|
||||
if entry is None:
|
||||
abort(404)
|
||||
return render_template('audit/view.html', entry=entry)
|
||||
|
||||
|
||||
# ── Purge old logs ────────────────────────────────────────────────────────────
|
||||
|
||||
PURGE_OPTIONS = {
|
||||
7: '7 days',
|
||||
30: '30 days',
|
||||
60: '60 days',
|
||||
90: '90 days',
|
||||
180: '180 days',
|
||||
365: '1 year',
|
||||
}
|
||||
|
||||
@bp.route('/purge', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def purge():
|
||||
"""Delete audit log entries older than the selected threshold.
|
||||
|
||||
Accepts a POST form field `older_than` (integer days).
|
||||
The purge itself is recorded as a new audit log entry so there is
|
||||
always a traceable record of who purged what and when.
|
||||
"""
|
||||
try:
|
||||
older_than = int(request.form.get('older_than', 0))
|
||||
except (ValueError, TypeError):
|
||||
older_than = 0
|
||||
|
||||
if older_than not in PURGE_OPTIONS:
|
||||
flash('Invalid purge threshold selected.', 'danger')
|
||||
return redirect(url_for('audit.index'))
|
||||
|
||||
cutoff = now_eastern() - timedelta(days=older_than)
|
||||
deleted = AuditLog.query.filter(AuditLog.created_at < cutoff).delete()
|
||||
db.session.flush()
|
||||
|
||||
label = PURGE_OPTIONS[older_than]
|
||||
log_action(
|
||||
ACTION_DELETE, 'AuditLog', None,
|
||||
f'Purged {deleted} log entries older than {label}',
|
||||
f'cutoff={cutoff.strftime("%Y-%m-%d %H:%M:%S")} UTC; deleted={deleted}',
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
logger.info(
|
||||
'AUDIT PURGE | deleted=%s | older_than=%s days | by=%s',
|
||||
deleted, older_than, request.remote_addr,
|
||||
)
|
||||
|
||||
flash(
|
||||
f'{deleted} audit log entr{"ies" if deleted != 1 else "y"} '
|
||||
f'older than {label} have been permanently deleted.',
|
||||
'success' if deleted else 'info',
|
||||
)
|
||||
return redirect(url_for('audit.index'))
|
||||
@@ -0,0 +1,519 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
|
||||
from urllib.parse import urlparse
|
||||
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, ForgotPasswordForm, ResetPasswordForm
|
||||
from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url
|
||||
import logging
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('auth', __name__, url_prefix='/auth')
|
||||
|
||||
|
||||
@bp.route('/login', methods=['GET', 'POST'])
|
||||
@limiter.limit('20 per minute; 5 per second')
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('dashboard.index'))
|
||||
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
user = User.query.filter_by(username=form.username.data).first()
|
||||
|
||||
if user and user.check_password(form.password.data):
|
||||
if not user.active:
|
||||
flash('Your account has been disabled. Please contact an administrator.', 'danger')
|
||||
return render_template('auth/login.html', form=form)
|
||||
if not user.password_set:
|
||||
flash(
|
||||
'Your account password has not been set yet. '
|
||||
'Please check your email for the account setup link.',
|
||||
'warning'
|
||||
)
|
||||
return 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 render_template('auth/login.html', form=form)
|
||||
|
||||
|
||||
@bp.route('/logout')
|
||||
@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'))
|
||||
|
||||
|
||||
@bp.route('/profile', methods=['GET', 'POST'])
|
||||
@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
|
||||
|
||||
form = ProfileForm(user=current_user, obj=current_user)
|
||||
|
||||
if form.validate_on_submit():
|
||||
current_user.full_name = form.full_name.data.strip() or None
|
||||
current_user.email = form.email.data
|
||||
|
||||
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)
|
||||
|
||||
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'))
|
||||
|
||||
# ── Activity stats ────────────────────────────────────────────────────
|
||||
total_inspections = Inspection.query.filter_by(inspector_id=current_user.id).count()
|
||||
completed_inspections = Inspection.query.filter_by(
|
||||
inspector_id=current_user.id, status='completed'
|
||||
).count()
|
||||
|
||||
recent_inspections = (
|
||||
Inspection.query
|
||||
.filter_by(inspector_id=current_user.id)
|
||||
.order_by(Inspection.inspection_date.desc())
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
|
||||
open_issues = Issue.query.filter_by(
|
||||
assigned_to=current_user.id, status='open'
|
||||
).count() if hasattr(Issue, 'assigned_to') else 0
|
||||
|
||||
return render_template(
|
||||
'auth/profile.html',
|
||||
form=form,
|
||||
total_inspections=total_inspections,
|
||||
completed_inspections=completed_inspections,
|
||||
recent_inspections=recent_inspections,
|
||||
open_issues=open_issues,
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/users')
|
||||
@login_required
|
||||
@admin_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()
|
||||
)
|
||||
|
||||
# Build a map of inspector_id -> assignment count for the Contracts column
|
||||
from app.models.inspector_assignment import InspectorAssignment
|
||||
from sqlalchemy import func
|
||||
rows = (
|
||||
db.session.query(
|
||||
InspectorAssignment.user_id,
|
||||
func.count(InspectorAssignment.id).label('cnt'),
|
||||
)
|
||||
.group_by(InspectorAssignment.user_id)
|
||||
.all()
|
||||
)
|
||||
inspector_contract_counts = {r.user_id: r.cnt for r in rows}
|
||||
|
||||
logger.info('AUTH | list_users | admin=%s | internal_users_count=%s',
|
||||
current_user.username, len(users))
|
||||
return render_template('auth/users.html', users=users,
|
||||
inspector_contract_counts=inspector_contract_counts)
|
||||
|
||||
|
||||
@bp.route('/users/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def create_user():
|
||||
form = UserForm()
|
||||
|
||||
# Directors may not assign roles — new users created by a director default
|
||||
# to inspector. Only admins may set an arbitrary role at creation time.
|
||||
director_editing = current_user.role == 'director'
|
||||
|
||||
if form.validate_on_submit():
|
||||
role = 'inspector' if director_editing else form.role.data
|
||||
user = User(
|
||||
username=form.username.data,
|
||||
full_name=form.full_name.data.strip() or None,
|
||||
email=form.email.data,
|
||||
role=role
|
||||
)
|
||||
user.set_password(form.password.data)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
logger.info('AUTH | user_create | admin_id=%s admin=%s new_user=%s role=%s',
|
||||
current_user.id, current_user.username, user.username, user.role)
|
||||
log_action(ACTION_CREATE, 'User', user.id, user.username,
|
||||
f'role={user.role}; email={user.email}')
|
||||
flash(f'User {user.username} created successfully.', 'success')
|
||||
return redirect(url_for('auth.list_users'))
|
||||
|
||||
return render_template('auth/user_form.html', form=form, title='Create User',
|
||||
director_editing=director_editing)
|
||||
|
||||
|
||||
@bp.route('/users/<int:user_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def edit_user(user_id):
|
||||
user = db.session.get(User, user_id)
|
||||
if user is None:
|
||||
abort(404)
|
||||
form = UserForm(user=user, obj=user)
|
||||
|
||||
# Directors may not change another user's role — that privilege is admin-only.
|
||||
# The role field is removed from the form for directors so it cannot be
|
||||
# submitted at all, and the existing role value is preserved on save.
|
||||
director_editing = current_user.role == 'director'
|
||||
|
||||
if form.validate_on_submit():
|
||||
user.username = form.username.data
|
||||
user.full_name = form.full_name.data.strip() or None
|
||||
user.email = form.email.data
|
||||
|
||||
if not director_editing:
|
||||
user.role = form.role.data
|
||||
|
||||
if form.password.data:
|
||||
user.set_password(form.password.data)
|
||||
|
||||
db.session.commit()
|
||||
logger.info('AUTH | user_edit | admin_id=%s admin=%s target_user_id=%s target_user=%s',
|
||||
current_user.id, current_user.username, user.id, user.username)
|
||||
log_action(ACTION_UPDATE, 'User', user.id, user.username,
|
||||
f'role={user.role}; email={user.email}')
|
||||
flash(f'User {user.username} updated successfully.', 'success')
|
||||
return redirect(url_for('auth.list_users'))
|
||||
|
||||
return render_template('auth/user_form.html', form=form, user=user,
|
||||
title='Edit User', director_editing=director_editing)
|
||||
|
||||
|
||||
@bp.route('/users/<int:user_id>/assign-contracts', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def assign_inspector_contracts(user_id):
|
||||
user = db.session.get(User, user_id)
|
||||
if user is None or user.role != 'inspector':
|
||||
abort(404)
|
||||
|
||||
from app.models.project import Project
|
||||
from app.models.inspector_assignment import InspectorAssignment
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||
|
||||
if request.method == 'POST':
|
||||
selected_ids = set(request.form.getlist('project_ids', type=int))
|
||||
existing = InspectorAssignment.query.filter_by(user_id=user_id).all()
|
||||
existing_pids = {a.project_id for a in existing}
|
||||
|
||||
for a in existing:
|
||||
if a.project_id not in selected_ids:
|
||||
db.session.delete(a)
|
||||
for pid in selected_ids:
|
||||
if pid not in existing_pids:
|
||||
db.session.add(InspectorAssignment(
|
||||
user_id = user_id,
|
||||
project_id = pid,
|
||||
created_at = now_eastern(),
|
||||
))
|
||||
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'User', user.id, user.username,
|
||||
f'inspector_assignments={sorted(selected_ids)}')
|
||||
flash(f'Contract assignments updated for {user.display_name}.', 'success')
|
||||
return redirect(url_for('auth.list_users'))
|
||||
|
||||
assigned_pids = {
|
||||
a.project_id
|
||||
for a in InspectorAssignment.query.filter_by(user_id=user_id).all()
|
||||
}
|
||||
return render_template('auth/inspector_assignments.html',
|
||||
user=user,
|
||||
projects=projects,
|
||||
assigned_pids=assigned_pids)
|
||||
|
||||
|
||||
@bp.route('/users/<int:user_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_user(user_id):
|
||||
user = db.session.get(User, user_id)
|
||||
if user is None:
|
||||
abort(404)
|
||||
|
||||
if user.id == current_user.id:
|
||||
flash('Cannot delete your own account.', 'danger')
|
||||
return redirect(url_for('auth.list_users'))
|
||||
|
||||
# Guard: block deletion if user has related records that would orphan data
|
||||
# or violate FK constraints. Issue.assigned_to and IssueComment.user_id carry
|
||||
# no ondelete clause, so MySQL defaults to RESTRICT — the DELETE would fail at
|
||||
# the DB level without these application-level checks and clear user-facing messages.
|
||||
if user.inspections.count() > 0:
|
||||
flash(
|
||||
f'Cannot delete "{user.username}" — they have existing inspection records. '
|
||||
'Deactivate the account instead.',
|
||||
'danger'
|
||||
)
|
||||
return redirect(url_for('auth.list_users'))
|
||||
|
||||
if user.assigned_issues.count() > 0:
|
||||
flash(
|
||||
f'Cannot delete "{user.username}" — they have issues assigned to them. '
|
||||
'Reassign or resolve those issues first, then deactivate the account.',
|
||||
'danger'
|
||||
)
|
||||
return redirect(url_for('auth.list_users'))
|
||||
|
||||
from app.models.issue import IssueComment
|
||||
if IssueComment.query.filter_by(user_id=user.id).count() > 0:
|
||||
flash(
|
||||
f'Cannot delete "{user.username}" — they have authored issue comments. '
|
||||
'Deactivate the account instead.',
|
||||
'danger'
|
||||
)
|
||||
return redirect(url_for('auth.list_users'))
|
||||
|
||||
from app.models.inspection import InspectionTemplate
|
||||
if InspectionTemplate.query.filter_by(created_by=user.id).count() > 0:
|
||||
flash(
|
||||
f'Cannot delete "{user.username}" — they have created inspection templates. '
|
||||
'Deactivate the account instead.',
|
||||
'danger'
|
||||
)
|
||||
return redirect(url_for('auth.list_users'))
|
||||
|
||||
username = user.username
|
||||
user_id = user.id
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
logger.info('AUTH | user_delete | admin_id=%s admin=%s deleted_user=%s',
|
||||
current_user.id, current_user.username, username)
|
||||
log_action(ACTION_DELETE, 'User', user_id, username)
|
||||
flash(f'User {username} deleted successfully.', 'success')
|
||||
return redirect(url_for('auth.list_users'))
|
||||
|
||||
@bp.route('/users/<int:user_id>/toggle-active', methods=['POST'])
|
||||
@login_required
|
||||
@admin_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,
|
||||
)
|
||||
log_action(
|
||||
ACTION_UPDATE, 'User', user.id, user.username,
|
||||
f'account {action_label} by {current_user.username}',
|
||||
)
|
||||
flash(f'User {user.username} has been {action_label}.', 'success')
|
||||
return redirect(safe_redirect_url(request.referrer, fallback=url_for('auth.list_users')))
|
||||
|
||||
# ── Notification Matrix ───────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/notification-matrix', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def notification_matrix():
|
||||
"""Admin-only notification matrix — controls who receives each event type."""
|
||||
import json as _json
|
||||
from app.models.notification_matrix import (
|
||||
NotificationMatrix, MATRIX_EVENTS, MATRIX_ROLES, MATRIX_DEFAULTS,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
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}'))
|
||||
|
||||
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'))
|
||||
|
||||
# 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,
|
||||
)
|
||||
|
||||
|
||||
# ── Forgot / Reset Password (public) ─────────────────────────────────────────
|
||||
|
||||
def _send_password_reset_email(user, token, base_url=None):
|
||||
"""Send a password-reset link email. Mirrors _send_invite_email in customers.py."""
|
||||
from flask import current_app, render_template_string, url_for as _url_for
|
||||
from flask_mail import Message
|
||||
from app import mail
|
||||
from urllib.parse import urlparse
|
||||
import threading
|
||||
|
||||
if not current_app.config.get('MAIL_SERVER'):
|
||||
logger.warning('RESET EMAIL SKIPPED | no MAIL_SERVER | user=%s', user.username)
|
||||
return
|
||||
|
||||
effective_base = (base_url or current_app.config.get('APP_BASE_URL', '')).rstrip('/')
|
||||
reset_link = f'{effective_base}{_url_for("auth.reset_password", token=token)}'
|
||||
host = urlparse(effective_base).netloc or 'janitorialqc.local'
|
||||
sender = f'noreply@{host}'
|
||||
|
||||
html_body = render_template_string("""<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;">
|
||||
<h2 style="color:#0d6efd;">Password Reset Request</h2>
|
||||
<p>Hi {{ name }},</p>
|
||||
<p>We received a request to reset your password for the Janitorial QC portal.
|
||||
Click the button below to choose a new password. This link expires in
|
||||
<strong>1 hour</strong>.</p>
|
||||
<p>
|
||||
<a href="{{ link }}"
|
||||
style="background:#0d6efd;color:#fff;padding:12px 24px;
|
||||
text-decoration:none;border-radius:4px;display:inline-block;font-weight:bold;">
|
||||
Reset My Password
|
||||
</a>
|
||||
</p>
|
||||
<p style="font-size:13px;color:#666;">
|
||||
If you did not request a password reset, you can safely ignore this email.
|
||||
Your password will not change.
|
||||
</p>
|
||||
<p style="font-size:13px;color:#888;">
|
||||
Or copy this URL:<br>
|
||||
<a href="{{ link }}" style="color:#0d6efd;">{{ link }}</a>
|
||||
</p>
|
||||
<hr style="border:none;border-top:1px solid #eee;margin-top:32px;">
|
||||
<p style="font-size:12px;color:#888;">Janitorial QC System — do not reply.</p>
|
||||
</body>
|
||||
</html>""", name=user.display_name, link=reset_link)
|
||||
|
||||
text_body = (
|
||||
f'Hi {user.display_name},\n\n'
|
||||
f'We received a request to reset your JQC password.\n'
|
||||
f'Click the link below to reset it (expires in 1 hour):\n\n{reset_link}\n\n'
|
||||
f'If you did not request this, ignore this email.\n\nJanitorial QC System'
|
||||
)
|
||||
|
||||
msg = Message(
|
||||
subject = '[JQC] Password reset request',
|
||||
sender = sender,
|
||||
recipients = [user.email],
|
||||
body = text_body,
|
||||
html = html_body,
|
||||
)
|
||||
|
||||
app = current_app._get_current_object()
|
||||
|
||||
def _send():
|
||||
with app.app_context():
|
||||
try:
|
||||
mail.send(msg)
|
||||
logger.info('RESET EMAIL SENT | to=%s | user=%s', user.email, user.username)
|
||||
except Exception as exc:
|
||||
logger.error('RESET EMAIL FAILED | to=%s | error=%s', user.email, exc)
|
||||
|
||||
threading.Thread(target=_send, daemon=True).start()
|
||||
|
||||
|
||||
@bp.route('/forgot-password', methods=['GET', 'POST'])
|
||||
@limiter.limit('10 per hour')
|
||||
def forgot_password():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('dashboard.index'))
|
||||
|
||||
form = ForgotPasswordForm()
|
||||
if form.validate_on_submit():
|
||||
user = User.query.filter_by(email=form.email.data.strip().lower()).first()
|
||||
if user and user.active:
|
||||
token = user.generate_set_password_token(expires_hours=1)
|
||||
db.session.commit()
|
||||
_send_password_reset_email(user, token, base_url=request.host_url)
|
||||
logger.info('AUTH | forgot_password | user=%s | email=%s', user.username, user.email)
|
||||
# Always show the same message — never reveal whether the email exists
|
||||
flash(
|
||||
'If an account with that email address exists, a password reset link '
|
||||
'has been sent. Please check your inbox (and spam folder).',
|
||||
'info'
|
||||
)
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
return render_template('auth/forgot_password.html', form=form)
|
||||
|
||||
|
||||
@bp.route('/reset-password/<token>', methods=['GET', 'POST'])
|
||||
def reset_password(token):
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('dashboard.index'))
|
||||
|
||||
user = User.verify_set_password_token(token)
|
||||
if user is None:
|
||||
flash('This password reset link is invalid or has expired.', 'danger')
|
||||
return redirect(url_for('auth.forgot_password'))
|
||||
|
||||
form = ResetPasswordForm()
|
||||
if form.validate_on_submit():
|
||||
user.set_password(form.password.data)
|
||||
user.clear_set_password_token()
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'User', user.id, user.username,
|
||||
'password reset via forgot-password link')
|
||||
flash('Your password has been reset successfully. Please log in.', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
return render_template('auth/reset_password.html', form=form, user=user)
|
||||
@@ -0,0 +1,140 @@
|
||||
# app/routes/broadcast.py
|
||||
# -----------------------
|
||||
# Admin-only route for composing and sending push notifications to all
|
||||
# iOS apps. Uses the existing Notification model and notify() utility —
|
||||
# broadcasts arrive on the iPad via the standard 60-second poll cycle
|
||||
# (GET /api/v1/notifications?since=...) and trigger a local banner via
|
||||
# deliverLocalNotification(). No APNs/FCM required.
|
||||
|
||||
import logging
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from app import db
|
||||
from app.models.broadcast import Broadcast
|
||||
from app.models.user import User
|
||||
from app.models.notification import Notification
|
||||
from app.utils.decorators import admin_required
|
||||
from app.utils.audit import log_action, ACTION_CREATE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('broadcast', __name__, url_prefix='/admin/broadcast')
|
||||
|
||||
# All roles that can hold an active iOS session
|
||||
BROADCAST_ROLES = ['inspector', 'project_manager', 'director', 'admin']
|
||||
|
||||
ROLE_LABELS = {
|
||||
'inspector': 'Inspectors',
|
||||
'project_manager': 'Project Managers',
|
||||
'director': 'Directors',
|
||||
'admin': 'Admins',
|
||||
}
|
||||
|
||||
|
||||
@bp.route('/', methods=['GET'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def index():
|
||||
"""Show the compose form and recent broadcast history."""
|
||||
history = (
|
||||
Broadcast.query
|
||||
.order_by(Broadcast.sent_at.desc())
|
||||
.limit(50)
|
||||
.all()
|
||||
)
|
||||
return render_template(
|
||||
'admin/broadcast.html',
|
||||
history=history,
|
||||
roles=BROADCAST_ROLES,
|
||||
role_labels=ROLE_LABELS,
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/send', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def send():
|
||||
"""
|
||||
Compose and send a broadcast notification.
|
||||
|
||||
Form fields
|
||||
-----------
|
||||
title str Notification title (required, max 255)
|
||||
body str Notification body text (required)
|
||||
roles[] list One or more role keys to target (required)
|
||||
"""
|
||||
title = (request.form.get('title') or '').strip()
|
||||
body = (request.form.get('body') or '').strip()
|
||||
target_roles = request.form.getlist('roles')
|
||||
|
||||
# ── Validation ─────────────────────────────────────────────────────────
|
||||
errors = []
|
||||
if not title:
|
||||
errors.append('Title is required.')
|
||||
elif len(title) > 255:
|
||||
errors.append('Title must be 255 characters or fewer.')
|
||||
if not body:
|
||||
errors.append('Message body is required.')
|
||||
elif len(body) > 500:
|
||||
errors.append('Message must be 500 characters or fewer.')
|
||||
valid_roles = [r for r in target_roles if r in BROADCAST_ROLES]
|
||||
if not valid_roles:
|
||||
errors.append('Select at least one target role.')
|
||||
|
||||
if errors:
|
||||
for e in errors:
|
||||
flash(e, 'danger')
|
||||
return redirect(url_for('broadcast.index'))
|
||||
|
||||
# ── Find target users (active only) ────────────────────────────────────
|
||||
recipients = (
|
||||
User.query
|
||||
.filter(User.role.in_(valid_roles), User.active == True) # noqa: E712
|
||||
.all()
|
||||
)
|
||||
if not recipients:
|
||||
flash('No active users found for the selected roles.', 'warning')
|
||||
return redirect(url_for('broadcast.index'))
|
||||
|
||||
# ── Create Notification rows ────────────────────────────────────────────
|
||||
# One row per recipient — the iOS poll picks them up in the next 60s cycle.
|
||||
# Using the same Notification model as all other in-app notifications means
|
||||
# no iOS code changes are needed: existing deliverLocalNotification() fires
|
||||
# a banner, and unreadNotificationCount increments as usual.
|
||||
for user in recipients:
|
||||
notif = Notification(
|
||||
user_id = user.id,
|
||||
title = title,
|
||||
body = body,
|
||||
link = None, # broadcasts are informational — no destination page
|
||||
event_type = 'admin_broadcast',
|
||||
is_read = False,
|
||||
)
|
||||
db.session.add(notif)
|
||||
|
||||
# ── Record the broadcast ────────────────────────────────────────────────
|
||||
broadcast = Broadcast(
|
||||
title = title,
|
||||
body = body,
|
||||
target_roles = valid_roles,
|
||||
sent_by_id = current_user.id,
|
||||
recipient_count = len(recipients),
|
||||
)
|
||||
db.session.add(broadcast)
|
||||
db.session.commit()
|
||||
|
||||
log_action(ACTION_CREATE, 'Broadcast', broadcast.id,
|
||||
f'"{title}" → {", ".join(valid_roles)} ({len(recipients)} users)')
|
||||
|
||||
logger.info(
|
||||
'BROADCAST | id=%d | title=%r | roles=%s | recipients=%d | by=%s',
|
||||
broadcast.id, title, valid_roles, len(recipients), current_user.username,
|
||||
)
|
||||
|
||||
flash(
|
||||
f'Broadcast sent to {len(recipients)} user(s) across '
|
||||
f'{", ".join(ROLE_LABELS[r] for r in valid_roles)}.',
|
||||
'success',
|
||||
)
|
||||
return redirect(url_for('broadcast.index'))
|
||||
@@ -0,0 +1,785 @@
|
||||
"""
|
||||
app/routes/customers.py
|
||||
-----------------------
|
||||
Customer Management — admin-only consolidated view.
|
||||
|
||||
Provides a single screen to:
|
||||
- List all customer-role users with their assignment summary
|
||||
- Create a new customer account
|
||||
- Edit an existing customer (username / email / password / active)
|
||||
- Manage assignments for a customer (add / remove)
|
||||
- Quick-disable / enable a customer account
|
||||
- View a customer's scoped facility access at a glance
|
||||
"""
|
||||
|
||||
import logging
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
from app.models.project import Project, CustomerAssignment
|
||||
from app.models.facility import Facility
|
||||
from app.utils.forms import CustomerUserForm, CustomerAssignmentForm, CustomerInviteForm, SetPasswordForm
|
||||
from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
|
||||
from app.utils.scope import get_customer_scope
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('customers', __name__, url_prefix='/customers')
|
||||
|
||||
|
||||
# ── List ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def index():
|
||||
"""Consolidated customer management dashboard."""
|
||||
customers = (
|
||||
User.query
|
||||
.filter_by(role='customer')
|
||||
.order_by(User.username)
|
||||
.all()
|
||||
)
|
||||
|
||||
customer_ids = [c.id for c in customers]
|
||||
|
||||
# ── Single bulk query for all assignments ─────────────────────────────
|
||||
# Replaces per-customer CustomerAssignment.query.filter_by(user_id=...) loop
|
||||
all_assignments = (
|
||||
CustomerAssignment.query
|
||||
.filter(CustomerAssignment.user_id.in_(customer_ids))
|
||||
.all()
|
||||
) if customer_ids else []
|
||||
|
||||
assignment_map = {c.id: [] for c in customers}
|
||||
for a in all_assignments:
|
||||
assignment_map[a.user_id].append(a)
|
||||
|
||||
# ── Single bulk query for all active facilities in assigned projects ──
|
||||
# Resolves facility scope for every customer without repeated DB round-trips.
|
||||
from collections import defaultdict
|
||||
assigned_project_ids = {a.project_id for a in all_assignments}
|
||||
|
||||
project_facilities_map = defaultdict(list) # project_id → [facility_id, ...]
|
||||
if assigned_project_ids:
|
||||
proj_facs = (
|
||||
Facility.query
|
||||
.filter(
|
||||
Facility.project_id.in_(assigned_project_ids),
|
||||
Facility.active == True,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for f in proj_facs:
|
||||
project_facilities_map[f.project_id].append(f.id)
|
||||
|
||||
scope_map = {} # user_id → sorted list[int] facility IDs
|
||||
for customer in customers:
|
||||
ids = set()
|
||||
for a in assignment_map[customer.id]:
|
||||
if a.facility_id:
|
||||
ids.add(a.facility_id)
|
||||
else:
|
||||
ids.update(project_facilities_map.get(a.project_id, []))
|
||||
scope_map[customer.id] = sorted(ids)
|
||||
|
||||
# All active projects for the assignment modal
|
||||
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||
|
||||
# ── Expired pending-setup invitations ─────────────────────────────────
|
||||
# Surface customer accounts whose invitation token has expired but
|
||||
# password_set is still False — they need a fresh invite to log in.
|
||||
from app.utils.time_utils import now_eastern
|
||||
expired_invitations = [
|
||||
c for c in customers
|
||||
if not c.password_set
|
||||
and c.set_password_token_expires is not None
|
||||
and c.set_password_token_expires < now_eastern()
|
||||
]
|
||||
|
||||
return render_template(
|
||||
'customers/index.html',
|
||||
customers = customers,
|
||||
assignment_map = assignment_map,
|
||||
scope_map = scope_map,
|
||||
projects = projects,
|
||||
expired_invitations = expired_invitations,
|
||||
)
|
||||
|
||||
|
||||
# ── Create customer (invitation flow) ────────────────────────────────────────
|
||||
|
||||
@bp.route('/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def create():
|
||||
"""Create a customer account via email invitation.
|
||||
|
||||
Admin enters Full Name and Email only. A temporary username is
|
||||
auto-generated from the email address. A one-time set-password link
|
||||
is emailed; the customer chooses their own username and password when
|
||||
they click it. The account is activated on completion.
|
||||
"""
|
||||
form = CustomerInviteForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
import re, secrets
|
||||
|
||||
full_name = form.full_name.data.strip()
|
||||
email = form.email.data.strip().lower()
|
||||
|
||||
# Auto-generate a temporary username from the email local part.
|
||||
# The customer replaces this with their preferred username when
|
||||
# they complete the set-password flow via the emailed link.
|
||||
base_uname = re.sub(r'[^a-z0-9._-]', '', email.split('@')[0])[:40] or 'customer'
|
||||
username = base_uname
|
||||
suffix = 1
|
||||
while User.query.filter_by(username=username).first():
|
||||
username = f'{base_uname}{suffix}'
|
||||
suffix += 1
|
||||
|
||||
# Create user with a random placeholder password (password_set=False
|
||||
# blocks login until the customer completes the set-password flow).
|
||||
user = User(
|
||||
username = username,
|
||||
full_name = full_name,
|
||||
email = email,
|
||||
role = 'customer',
|
||||
active = True,
|
||||
password_set = False,
|
||||
)
|
||||
user.set_password(secrets.token_hex(32))
|
||||
db.session.add(user)
|
||||
db.session.flush()
|
||||
|
||||
token = user.generate_set_password_token(expires_hours=72)
|
||||
db.session.commit()
|
||||
|
||||
logger.info('CUSTOMERS | invite | admin=%s new_customer=%s email=%s',
|
||||
current_user.username, user.username, user.email)
|
||||
log_action(ACTION_CREATE, 'User', user.id, user.username,
|
||||
f'role=customer; email={user.email}; invite_sent=True')
|
||||
|
||||
_send_invite_email(user, token, base_url=request.host_url)
|
||||
|
||||
flash(
|
||||
f'Customer account created for {full_name}. '
|
||||
f'An invitation email has been sent to {email} with a link to set their username and password.',
|
||||
'success'
|
||||
)
|
||||
return redirect(url_for('customers.manage', customer_id=user.id))
|
||||
|
||||
return render_template('customers/invite.html', form=form)
|
||||
|
||||
|
||||
def _send_invite_email(user, token, base_url=None):
|
||||
"""Send the account setup email to a newly created customer."""
|
||||
from flask import current_app, render_template_string
|
||||
from flask_mail import Message
|
||||
from app import mail
|
||||
from urllib.parse import urlparse
|
||||
import threading
|
||||
|
||||
if not current_app.config.get('MAIL_SERVER'):
|
||||
logger.warning('INVITE EMAIL SKIPPED | no MAIL_SERVER | user=%s', user.username)
|
||||
return
|
||||
|
||||
effective_base = (base_url or current_app.config.get('APP_BASE_URL', '')).rstrip('/')
|
||||
setup_link = f'{effective_base}{url_for("customers.set_password", token=token)}'
|
||||
|
||||
host = urlparse(effective_base).netloc or 'janitorialqc.local'
|
||||
sender = f'noreply@{host}'
|
||||
|
||||
html_body = render_template_string("""<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;">
|
||||
<h2 style="color:#0d6efd;">Welcome to the Janitorial QC System</h2>
|
||||
<p>Hi {{ name }},</p>
|
||||
<p>An account has been created for you on the Janitorial QC (JQC) portal.
|
||||
To get started, please set your password using the button below.</p>
|
||||
<p>
|
||||
<a href="{{ link }}"
|
||||
style="background:#0d6efd;color:#fff;padding:12px 24px;
|
||||
text-decoration:none;border-radius:4px;display:inline-block;font-weight:bold;">
|
||||
Set My Password
|
||||
</a>
|
||||
</p>
|
||||
<p style="font-size:13px;color:#666;">
|
||||
This link expires in <strong>72 hours</strong>. If you did not expect this email,
|
||||
you can safely ignore it.
|
||||
</p>
|
||||
<p style="font-size:13px;color:#888;">
|
||||
Or copy this URL:<br>
|
||||
<a href="{{ link }}" style="color:#0d6efd;">{{ link }}</a>
|
||||
</p>
|
||||
<hr style="border:none;border-top:1px solid #eee;margin-top:32px;">
|
||||
<p style="font-size:12px;color:#888;">Janitorial QC System — do not reply.</p>
|
||||
</body>
|
||||
</html>""", name=user.display_name, link=setup_link)
|
||||
|
||||
text_body = (
|
||||
f'Hi {user.display_name},\n\n'
|
||||
f'An account has been created for you on the Janitorial QC portal.\n'
|
||||
f'Set your password here:\n\n{setup_link}\n\n'
|
||||
f'This link expires in 72 hours.\n\nJanitorial QC System'
|
||||
)
|
||||
|
||||
msg = Message(
|
||||
subject = '[JQC] Your account is ready — please set your password',
|
||||
sender = sender,
|
||||
recipients = [user.email],
|
||||
body = text_body,
|
||||
html = html_body,
|
||||
)
|
||||
|
||||
app = current_app._get_current_object()
|
||||
|
||||
def _send():
|
||||
with app.app_context():
|
||||
try:
|
||||
mail.send(msg)
|
||||
logger.info('INVITE EMAIL SENT | to=%s | user=%s', user.email, user.username)
|
||||
except Exception as exc:
|
||||
logger.error('INVITE EMAIL FAILED | to=%s | error=%s', user.email, exc)
|
||||
|
||||
threading.Thread(target=_send, daemon=True).start()
|
||||
|
||||
|
||||
# ── Resend invitation email ───────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:customer_id>/resend-invite', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def resend_invite(customer_id):
|
||||
"""Generate a fresh token and resend the set-password invitation email."""
|
||||
customer = db.session.get(User, customer_id)
|
||||
if customer is None:
|
||||
abort(404)
|
||||
if customer.role != 'customer':
|
||||
flash('This action is only for customer accounts.', 'warning')
|
||||
return redirect(url_for('customers.index'))
|
||||
|
||||
token = customer.generate_set_password_token(expires_hours=72)
|
||||
customer.password_set = False
|
||||
db.session.commit()
|
||||
|
||||
logger.info('CUSTOMERS | resend_invite | admin=%s customer=%s',
|
||||
current_user.username, customer.username)
|
||||
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
|
||||
f'invite resent by {current_user.username}')
|
||||
|
||||
_send_invite_email(customer, token, base_url=request.host_url)
|
||||
flash(f'Invitation email resent to {customer.email}.', 'success')
|
||||
return redirect(url_for('customers.manage', customer_id=customer_id))
|
||||
|
||||
|
||||
# ── Public: set password via token ────────────────────────────────────────────
|
||||
|
||||
@bp.route('/set-password/<token>', methods=['GET', 'POST'])
|
||||
def set_password(token):
|
||||
"""Public page — customer sets their password via the emailed link."""
|
||||
from app.utils.forms import SetPasswordForm
|
||||
user = User.verify_set_password_token(token)
|
||||
if user is None:
|
||||
flash(
|
||||
'This password setup link is invalid or has expired. '
|
||||
'Please contact your administrator to resend the invitation.',
|
||||
'danger'
|
||||
)
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
form = SetPasswordForm()
|
||||
if form.validate_on_submit():
|
||||
user.username = form.username.data.strip()
|
||||
user.password_set = True
|
||||
user.set_password(form.password.data)
|
||||
user.clear_set_password_token()
|
||||
db.session.commit()
|
||||
|
||||
logger.info('CUSTOMERS | password_set | user=%s', user.username)
|
||||
log_action(ACTION_UPDATE, 'User', user.id, user.username,
|
||||
'customer chose username and password via invite link')
|
||||
|
||||
flash('Your account is ready. You can now log in.', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
return render_template('customers/set_password.html', form=form, user=user)
|
||||
|
||||
|
||||
# ── Edit customer ─────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Edit customer ─────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:customer_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def edit(customer_id):
|
||||
customer = db.session.get(User, customer_id)
|
||||
if customer is None:
|
||||
abort(404)
|
||||
if customer.role != 'customer':
|
||||
flash('This page is only for customer accounts.', 'warning')
|
||||
return redirect(url_for('customers.index'))
|
||||
|
||||
form = CustomerUserForm(user=customer, obj=customer)
|
||||
|
||||
if form.validate_on_submit():
|
||||
customer.username = form.username.data
|
||||
customer.full_name = form.full_name.data.strip() or None
|
||||
customer.email = form.email.data
|
||||
if form.password.data:
|
||||
customer.set_password(form.password.data)
|
||||
db.session.commit()
|
||||
logger.info('CUSTOMERS | edit | admin=%s customer_id=%s username=%s',
|
||||
current_user.username, customer.id, customer.username)
|
||||
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
|
||||
f'email={customer.email}; updated_via=customer_mgmt')
|
||||
flash(f'Customer "{customer.username}" updated successfully.', 'success')
|
||||
return redirect(url_for('customers.manage', customer_id=customer.id))
|
||||
|
||||
return render_template('customers/form.html', form=form, customer=customer,
|
||||
title='Edit Customer Account')
|
||||
|
||||
|
||||
# ── Customer detail / assignment management ───────────────────────────────────
|
||||
|
||||
@bp.route('/<int:customer_id>')
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def manage(customer_id):
|
||||
"""Single-customer detail page: profile + all assignments."""
|
||||
customer = db.session.get(User, customer_id)
|
||||
if customer is None:
|
||||
abort(404)
|
||||
if customer.role != 'customer':
|
||||
flash('This page is only for customer accounts.', 'warning')
|
||||
return redirect(url_for('customers.index'))
|
||||
|
||||
assignments = CustomerAssignment.query.filter_by(user_id=customer_id).all()
|
||||
facility_ids = get_customer_scope(customer) or []
|
||||
facilities = (
|
||||
Facility.query
|
||||
.filter(Facility.id.in_(facility_ids), Facility.active == True)
|
||||
.order_by(Facility.name)
|
||||
.all()
|
||||
) if facility_ids else []
|
||||
|
||||
# Assignment form (populated here so it can be rendered inline)
|
||||
aform = CustomerAssignmentForm()
|
||||
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||
aform.user_id.choices = [(customer.id, customer.username)]
|
||||
aform.facility_id.choices = [(0, '— All facilities in contract —')]
|
||||
|
||||
return render_template(
|
||||
'customers/manage.html',
|
||||
customer = customer,
|
||||
assignments = assignments,
|
||||
facilities = facilities,
|
||||
aform = aform,
|
||||
projects = projects,
|
||||
)
|
||||
|
||||
|
||||
# ── Add assignment (from customer detail page) ────────────────────────────────
|
||||
|
||||
@bp.route('/<int:customer_id>/assignments/add', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def add_assignment(customer_id):
|
||||
customer = db.session.get(User, customer_id)
|
||||
if customer is None:
|
||||
abort(404)
|
||||
if customer.role != 'customer':
|
||||
flash('Assignments are only for customer accounts.', 'warning')
|
||||
return redirect(url_for('customers.index'))
|
||||
|
||||
project_id = request.form.get('project_id', type=int)
|
||||
facility_id = request.form.get('facility_id', type=int) or None
|
||||
|
||||
if not project_id:
|
||||
flash('Please select a contract.', 'warning')
|
||||
return redirect(url_for('customers.manage', customer_id=customer_id))
|
||||
|
||||
project = db.session.get(Project, project_id)
|
||||
if project is None:
|
||||
abort(404)
|
||||
|
||||
# Guard: duplicate assignment
|
||||
existing = CustomerAssignment.query.filter_by(
|
||||
user_id = customer_id,
|
||||
project_id = project_id,
|
||||
facility_id = facility_id,
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
flash('That assignment already exists.', 'warning')
|
||||
return redirect(url_for('customers.manage', customer_id=customer_id))
|
||||
|
||||
assignment = CustomerAssignment(
|
||||
user_id = customer_id,
|
||||
project_id = project_id,
|
||||
facility_id = facility_id,
|
||||
)
|
||||
db.session.add(assignment)
|
||||
db.session.commit()
|
||||
|
||||
scope_label = f'facility_id={facility_id}' if facility_id else 'all facilities'
|
||||
logger.info('CUSTOMERS | assignment_add | admin=%s customer=%s project_id=%s scope=%s',
|
||||
current_user.username, customer.username, project_id, scope_label)
|
||||
log_action(ACTION_CREATE, 'CustomerAssignment', assignment.id,
|
||||
f'{customer.username} → {project.name}',
|
||||
f'scope={scope_label}')
|
||||
flash(f'Assignment added: "{customer.username}" → "{project.name}".', 'success')
|
||||
return redirect(url_for('customers.manage', customer_id=customer_id))
|
||||
|
||||
|
||||
# ── Remove assignment ─────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/assignments/<int:assignment_id>/remove', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def remove_assignment(assignment_id):
|
||||
assignment = db.session.get(CustomerAssignment, assignment_id)
|
||||
if assignment is None:
|
||||
abort(404)
|
||||
customer_id = assignment.user_id
|
||||
customer = db.session.get(User, customer_id)
|
||||
project = db.session.get(Project, assignment.project_id)
|
||||
|
||||
username = customer.username if customer else f'user_id={customer_id}'
|
||||
project_name = project.name if project else f'project_id={assignment.project_id}'
|
||||
snap_id = assignment.id
|
||||
|
||||
db.session.delete(assignment)
|
||||
db.session.commit()
|
||||
logger.info('CUSTOMERS | assignment_remove | admin=%s customer=%s project=%s',
|
||||
current_user.username, username, project_name)
|
||||
log_action(ACTION_DELETE, 'CustomerAssignment', snap_id,
|
||||
f'{username} → {project_name}')
|
||||
flash(f'Assignment removed for "{username}".', 'success')
|
||||
return redirect(url_for('customers.manage', customer_id=customer_id))
|
||||
|
||||
|
||||
# ── Toggle active ─────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:customer_id>/toggle-active', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def toggle_active(customer_id):
|
||||
customer = db.session.get(User, customer_id)
|
||||
if customer is None:
|
||||
abort(404)
|
||||
if customer.role != 'customer':
|
||||
flash('This action is only for customer accounts.', 'warning')
|
||||
return redirect(url_for('customers.index'))
|
||||
|
||||
customer.active = not customer.active
|
||||
db.session.commit()
|
||||
|
||||
label = 'enabled' if customer.active else 'disabled'
|
||||
logger.info('CUSTOMERS | toggle_active | admin=%s customer=%s action=%s',
|
||||
current_user.username, customer.username, label)
|
||||
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
|
||||
f'account {label} via customer_mgmt by {current_user.username}')
|
||||
flash(f'Customer "{customer.username}" has been {label}.', 'success')
|
||||
return redirect(safe_redirect_url(request.referrer, fallback=url_for('customers.index')))
|
||||
|
||||
|
||||
# ── AJAX: facilities for a project (used by add-assignment form) ──────────────
|
||||
|
||||
|
||||
# ── CSV template download ─────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/import/template')
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def import_template():
|
||||
"""Download a blank CSV template showing the expected import format."""
|
||||
import csv, io
|
||||
from flask import Response
|
||||
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow([
|
||||
'username', 'email', 'password',
|
||||
'project_name', 'facility_name',
|
||||
])
|
||||
writer.writerow([
|
||||
'jane.smith', 'jane@acme.com', 'SecurePass1!',
|
||||
'Acme Contract', 'Downtown Office',
|
||||
])
|
||||
writer.writerow([
|
||||
'bob.jones', 'bob@acme.com', 'SecurePass2!',
|
||||
'Acme Contract', '',
|
||||
])
|
||||
buf.seek(0)
|
||||
return Response(
|
||||
buf.getvalue(),
|
||||
mimetype='text/csv',
|
||||
headers={'Content-Disposition': 'attachment; filename="customer_import_template.csv"'},
|
||||
)
|
||||
|
||||
|
||||
# ── Bulk import (upload → preview → confirm) ──────────────────────────────────
|
||||
|
||||
@bp.route('/import', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def bulk_import():
|
||||
"""Two-phase CSV import for customer accounts.
|
||||
|
||||
Phase 1 (GET / POST with file):
|
||||
Parse and validate the CSV, return a preview of what will be created.
|
||||
No database writes occur here.
|
||||
|
||||
Phase 2 (POST with confirmed=1):
|
||||
Write all validated rows to the database.
|
||||
|
||||
CSV columns
|
||||
-----------
|
||||
username : required — must be unique across users
|
||||
email : required — must be unique across users
|
||||
password : required — min 8 characters
|
||||
project_name : optional — must match an existing active Project name exactly
|
||||
facility_name : optional — if given, must match an active Facility within the project
|
||||
|
||||
One row = one user. A user may have at most one assignment per import row;
|
||||
import the same username on multiple rows to assign them to multiple projects.
|
||||
Duplicate username rows after the first are treated as additional assignments.
|
||||
"""
|
||||
import csv, io
|
||||
from flask import session as _session
|
||||
|
||||
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||
proj_by_name = {p.name.strip().lower(): p for p in projects}
|
||||
|
||||
# ── Phase 2: commit confirmed rows ────────────────────────────────────
|
||||
if request.method == 'POST' and request.form.get('confirmed') == '1':
|
||||
import json
|
||||
rows_json = request.form.get('rows_json', '[]')
|
||||
try:
|
||||
rows = json.loads(rows_json)
|
||||
except Exception:
|
||||
flash('Import session expired. Please re-upload the file.', 'danger')
|
||||
return redirect(url_for('customers.bulk_import'))
|
||||
|
||||
created_users = 0
|
||||
created_assign = 0
|
||||
skipped = 0
|
||||
|
||||
# Track users created in this batch (username → User) so duplicate
|
||||
# rows for the same username add assignments rather than re-creating.
|
||||
batch_users = {}
|
||||
|
||||
for row in rows:
|
||||
uname = row['username']
|
||||
email = row['email']
|
||||
pw = row['password']
|
||||
proj_id = row.get('project_id')
|
||||
fac_id = row.get('facility_id')
|
||||
|
||||
# Get or create user
|
||||
user = (
|
||||
batch_users.get(uname)
|
||||
or User.query.filter_by(username=uname).first()
|
||||
)
|
||||
|
||||
if user is None:
|
||||
user = User(
|
||||
username = uname,
|
||||
email = email,
|
||||
role = 'customer',
|
||||
active = True,
|
||||
)
|
||||
user.set_password(pw)
|
||||
db.session.add(user)
|
||||
db.session.flush() # populate user.id before assignment
|
||||
batch_users[uname] = user
|
||||
created_users += 1
|
||||
log_action(ACTION_CREATE, 'User', user.id, user.username,
|
||||
f'role=customer; email={email}; source=bulk_import')
|
||||
logger.info('BULK IMPORT | user_created | username=%s email=%s by=%s',
|
||||
uname, email, current_user.username)
|
||||
|
||||
# Create assignment if a project was specified
|
||||
if proj_id:
|
||||
existing = CustomerAssignment.query.filter_by(
|
||||
user_id = user.id,
|
||||
project_id = proj_id,
|
||||
facility_id = fac_id or None,
|
||||
).first()
|
||||
if not existing:
|
||||
assign = CustomerAssignment(
|
||||
user_id = user.id,
|
||||
project_id = proj_id,
|
||||
facility_id = fac_id or None,
|
||||
)
|
||||
db.session.add(assign)
|
||||
db.session.flush() # populate assign.id before audit log
|
||||
created_assign += 1
|
||||
log_action(ACTION_CREATE, 'CustomerAssignment', assign.id,
|
||||
f'{uname} → project_id={proj_id}',
|
||||
f'facility_id={fac_id}; source=bulk_import')
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
db.session.commit()
|
||||
logger.info(
|
||||
'BULK IMPORT COMMITTED | by=%s | users=%s | assignments=%s | skipped=%s',
|
||||
current_user.username, created_users, created_assign, skipped,
|
||||
)
|
||||
flash(
|
||||
f'Import complete: {created_users} user(s) created, '
|
||||
f'{created_assign} assignment(s) added'
|
||||
+ (f', {skipped} duplicate assignment(s) skipped.' if skipped else '.'),
|
||||
'success',
|
||||
)
|
||||
return redirect(url_for('customers.index'))
|
||||
|
||||
# ── Phase 1: parse and validate ───────────────────────────────────────
|
||||
preview_rows = []
|
||||
errors = []
|
||||
raw_valid_rows = [] # serialisable dicts passed to phase 2 via hidden field
|
||||
|
||||
if request.method == 'POST':
|
||||
file = request.files.get('csv_file')
|
||||
|
||||
if not file or not file.filename:
|
||||
flash('Please select a CSV file to upload.', 'warning')
|
||||
return render_template('customers/import.html', projects=projects)
|
||||
|
||||
if not file.filename.lower().endswith('.csv'):
|
||||
flash('Only .csv files are accepted.', 'danger')
|
||||
return render_template('customers/import.html', projects=projects)
|
||||
|
||||
try:
|
||||
stream = io.StringIO(file.stream.read().decode('utf-8-sig'))
|
||||
reader = csv.DictReader(stream)
|
||||
raw_rows = list(reader)
|
||||
except Exception as exc:
|
||||
flash(f'Could not parse file: {exc}', 'danger')
|
||||
return render_template('customers/import.html', projects=projects)
|
||||
|
||||
required_cols = {'username', 'email', 'password'}
|
||||
if not required_cols.issubset(set(reader.fieldnames or [])):
|
||||
flash(
|
||||
f'CSV is missing required columns: {required_cols - set(reader.fieldnames or [])}. '
|
||||
'Download the template to see the expected format.',
|
||||
'danger',
|
||||
)
|
||||
return render_template('customers/import.html', projects=projects)
|
||||
|
||||
# Track usernames seen in this file to catch intra-file duplicates
|
||||
seen_usernames = {} # username → first row index (1-based)
|
||||
seen_emails = {}
|
||||
|
||||
for i, raw in enumerate(raw_rows, start=2): # row 1 = header
|
||||
row_errors = []
|
||||
|
||||
uname = (raw.get('username') or '').strip()
|
||||
email = (raw.get('email') or '').strip()
|
||||
pw = (raw.get('password') or '').strip()
|
||||
pname = (raw.get('project_name') or '').strip()
|
||||
fname = (raw.get('facility_name') or '').strip()
|
||||
|
||||
if not uname:
|
||||
row_errors.append('username is required')
|
||||
if not email:
|
||||
row_errors.append('email is required')
|
||||
if not pw:
|
||||
row_errors.append('password is required')
|
||||
elif len(pw) < 8:
|
||||
row_errors.append('password must be at least 8 characters')
|
||||
|
||||
# Duplicate username within file (first occurrence creates the user;
|
||||
# subsequent occurrences add assignments — that's intentional)
|
||||
if uname:
|
||||
if uname in seen_usernames:
|
||||
# Allowed only if it's an additional assignment row
|
||||
pass
|
||||
else:
|
||||
seen_usernames[uname] = i
|
||||
# Check DB uniqueness only for new usernames
|
||||
if User.query.filter_by(username=uname).first():
|
||||
row_errors.append(f'username "{uname}" already exists in the system')
|
||||
|
||||
if email:
|
||||
if email in seen_emails:
|
||||
row_errors.append(f'email "{email}" appears more than once in this file')
|
||||
else:
|
||||
seen_emails[email] = i
|
||||
if User.query.filter_by(email=email).first():
|
||||
row_errors.append(f'email "{email}" already exists in the system')
|
||||
|
||||
# Resolve project
|
||||
project = None
|
||||
facility = None
|
||||
proj_id = None
|
||||
fac_id = None
|
||||
|
||||
if pname:
|
||||
project = proj_by_name.get(pname.lower())
|
||||
if project is None:
|
||||
row_errors.append(f'contract "{pname}" not found or inactive')
|
||||
else:
|
||||
proj_id = project.id
|
||||
if fname:
|
||||
from app.models.facility import Facility
|
||||
facility = Facility.query.filter(
|
||||
Facility.project_id == project.id,
|
||||
Facility.active == True,
|
||||
db.func.lower(Facility.name) == fname.lower(),
|
||||
).first()
|
||||
if facility is None:
|
||||
row_errors.append(
|
||||
f'facility "{fname}" not found in contract "{pname}"'
|
||||
)
|
||||
else:
|
||||
fac_id = facility.id
|
||||
elif fname:
|
||||
row_errors.append('facility_name requires project_name to also be set')
|
||||
|
||||
status = 'error' if row_errors else 'ok'
|
||||
preview_rows.append({
|
||||
'row': i,
|
||||
'username': uname,
|
||||
'email': email,
|
||||
'project': project.name if project else '—',
|
||||
'facility': facility.name if facility else ('All' if project else '—'),
|
||||
'status': status,
|
||||
'errors': row_errors,
|
||||
})
|
||||
|
||||
if not row_errors:
|
||||
raw_valid_rows.append({
|
||||
'username': uname,
|
||||
'email': email,
|
||||
'password': pw,
|
||||
'project_id': proj_id,
|
||||
'facility_id': fac_id,
|
||||
})
|
||||
else:
|
||||
errors.extend(row_errors)
|
||||
|
||||
import json
|
||||
return render_template(
|
||||
'customers/import.html',
|
||||
projects = projects,
|
||||
preview_rows = preview_rows,
|
||||
has_errors = bool(errors),
|
||||
valid_count = len(raw_valid_rows),
|
||||
rows_json = json.dumps(raw_valid_rows),
|
||||
)
|
||||
|
||||
@bp.route('/facilities-for-project/<int:project_id>')
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def facilities_for_project(project_id):
|
||||
from flask import jsonify
|
||||
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()
|
||||
return jsonify([{'id': f.id, 'name': f.name} for f in facilities])
|
||||
@@ -0,0 +1,427 @@
|
||||
import logging
|
||||
from flask import Blueprint, render_template
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.inspection import Inspection, InspectionTemplate
|
||||
from app.models.facility import Facility
|
||||
from app.models.issue import Issue
|
||||
from app.models.user import User
|
||||
from app.utils.sla import sla_status, SLA_HOURS
|
||||
from app.utils.scope import get_customer_scope, get_inspector_scope
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime, timedelta
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
bp = Blueprint('dashboard', __name__)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
@bp.route('/dashboard')
|
||||
@login_required
|
||||
def index():
|
||||
now = now_eastern()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_end = today_start + timedelta(days=1)
|
||||
|
||||
is_inspector = current_user.role == 'inspector'
|
||||
is_privileged = current_user.role in ['admin', 'director']
|
||||
is_customer = current_user.role == 'customer'
|
||||
is_project_manager = current_user.role == 'project_manager'
|
||||
|
||||
# Resolve facility scope
|
||||
customer_facility_ids = get_customer_scope(current_user) # None for non-customers
|
||||
inspector_facility_ids = get_inspector_scope(current_user) # None for non-inspectors
|
||||
|
||||
# ── Today's stats (inspector: own work within contracted facilities) ───
|
||||
base_q = Inspection.query
|
||||
if is_inspector:
|
||||
if not inspector_facility_ids:
|
||||
base_q = base_q.filter(False)
|
||||
else:
|
||||
base_q = base_q.filter(
|
||||
Inspection.facility_id.in_(inspector_facility_ids),
|
||||
Inspection.inspector_id == current_user.id,
|
||||
)
|
||||
elif is_customer:
|
||||
if not customer_facility_ids:
|
||||
base_q = base_q.filter(False) # no access
|
||||
else:
|
||||
base_q = base_q.filter(Inspection.facility_id.in_(customer_facility_ids))
|
||||
|
||||
today_inspections = base_q.filter(
|
||||
Inspection.inspection_date >= today_start,
|
||||
Inspection.inspection_date < today_end,
|
||||
).count()
|
||||
|
||||
completed_today = base_q.filter(
|
||||
Inspection.status == 'completed',
|
||||
Inspection.inspection_date >= today_start,
|
||||
Inspection.inspection_date < today_end,
|
||||
).count()
|
||||
|
||||
# ── Open issues (inspector: all issues in contracted facilities) ───────
|
||||
open_issues_q = Issue.query.filter(Issue.status.in_(['open', 'in_progress']))
|
||||
if is_inspector:
|
||||
if not inspector_facility_ids:
|
||||
open_issues_q = open_issues_q.filter(False)
|
||||
else:
|
||||
from app.models.facility import Area
|
||||
open_issues_q = open_issues_q.outerjoin(
|
||||
Area, Issue.area_id == Area.id
|
||||
).filter(db.or_(
|
||||
Issue.facility_id.in_(inspector_facility_ids),
|
||||
Area.facility_id.in_(inspector_facility_ids)
|
||||
))
|
||||
elif is_customer:
|
||||
if not customer_facility_ids:
|
||||
open_issues_q = open_issues_q.filter(False)
|
||||
else:
|
||||
from app.models.facility import Area
|
||||
open_issues_q = open_issues_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)
|
||||
))
|
||||
|
||||
# Single query — derive count from the list to avoid hitting the DB twice
|
||||
open_issues_all = open_issues_q.all()
|
||||
open_issues = len(open_issues_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'),
|
||||
}
|
||||
|
||||
# ── Issues resolved today ─────────────────────────────────────────────────
|
||||
resolved_today_q = Issue.query.filter(
|
||||
Issue.status == 'resolved',
|
||||
Issue.resolved_at >= today_start,
|
||||
Issue.resolved_at < today_end,
|
||||
)
|
||||
if is_inspector:
|
||||
if not inspector_facility_ids:
|
||||
resolved_today_q = resolved_today_q.filter(False)
|
||||
else:
|
||||
from app.models.facility import Area as _Area
|
||||
resolved_today_q = resolved_today_q.outerjoin(
|
||||
_Area, Issue.area_id == _Area.id
|
||||
).filter(db.or_(
|
||||
Issue.facility_id.in_(inspector_facility_ids),
|
||||
_Area.facility_id.in_(inspector_facility_ids),
|
||||
))
|
||||
elif is_customer:
|
||||
if not customer_facility_ids:
|
||||
resolved_today_q = resolved_today_q.filter(False)
|
||||
else:
|
||||
from app.models.facility import Area as _Area
|
||||
resolved_today_q = resolved_today_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),
|
||||
))
|
||||
resolved_today = resolved_today_q.count()
|
||||
|
||||
# ── Recent inspections ─────────────────────────────────────────────────
|
||||
recent_q = Inspection.query.order_by(Inspection.inspection_date.desc())
|
||||
if is_inspector:
|
||||
if not inspector_facility_ids:
|
||||
recent_q = recent_q.filter(False)
|
||||
else:
|
||||
recent_q = recent_q.filter(
|
||||
Inspection.facility_id.in_(inspector_facility_ids),
|
||||
Inspection.inspector_id == current_user.id,
|
||||
)
|
||||
elif is_customer:
|
||||
if customer_facility_ids:
|
||||
recent_q = recent_q.filter(Inspection.facility_id.in_(customer_facility_ids))
|
||||
else:
|
||||
recent_q = recent_q.filter(False)
|
||||
recent_inspections = recent_q.limit(5).all()
|
||||
|
||||
# ── Pending follow-up inspections ────────────────────────────────────
|
||||
# follow_ups is a lazy='dynamic' relationship — comparing it to None does
|
||||
# NOT produce a "has no rows" predicate for dynamic relationships. The
|
||||
# correct idiom is ~.any(), which generates EXISTS (SELECT 1 FROM inspections
|
||||
# WHERE parent_inspection_id = inspections.id). This matches the identical
|
||||
# filter used in routes/inspections.py:follow_up_filter.
|
||||
followup_q = Inspection.query.filter_by(
|
||||
follow_up_required=True, status='completed'
|
||||
).filter(~Inspection.follow_ups.any())
|
||||
if is_inspector:
|
||||
if not inspector_facility_ids:
|
||||
followup_q = followup_q.filter(False)
|
||||
else:
|
||||
followup_q = followup_q.filter(
|
||||
Inspection.facility_id.in_(inspector_facility_ids),
|
||||
Inspection.inspector_id == current_user.id,
|
||||
)
|
||||
elif is_customer:
|
||||
if customer_facility_ids:
|
||||
followup_q = followup_q.filter(Inspection.facility_id.in_(customer_facility_ids))
|
||||
else:
|
||||
followup_q = followup_q.filter(False)
|
||||
pending_followups = followup_q.count()
|
||||
|
||||
# ── System stats (admin/director) ────────────────────────────────────────
|
||||
total_facilities = Facility.query.filter_by(active=True).count() if is_privileged else 0
|
||||
total_templates = InspectionTemplate.query.count() if is_privileged else 0
|
||||
total_users = User.query.count() if current_user.role == 'admin' else 0
|
||||
|
||||
# ── Customer: scoped facilities summary ───────────────────────────────
|
||||
customer_facilities = []
|
||||
if is_customer and customer_facility_ids:
|
||||
customer_facilities = Facility.query.filter(
|
||||
Facility.id.in_(customer_facility_ids),
|
||||
Facility.active == True,
|
||||
).order_by(Facility.name).all()
|
||||
|
||||
# ── SLA summary (open + in_progress issues, scoped) ───────────────────
|
||||
sla_q = Issue.query.filter(Issue.status.in_(['open', 'in_progress']))
|
||||
if is_inspector and inspector_facility_ids:
|
||||
from app.models.facility import Area
|
||||
sla_q = sla_q.outerjoin(Area, Issue.area_id == Area.id).filter(
|
||||
db.or_(
|
||||
Issue.facility_id.in_(inspector_facility_ids),
|
||||
Area.facility_id.in_(inspector_facility_ids)
|
||||
)
|
||||
)
|
||||
elif is_customer and customer_facility_ids:
|
||||
from app.models.facility import Area
|
||||
sla_q = sla_q.outerjoin(Area, Issue.area_id == Area.id).filter(
|
||||
db.or_(
|
||||
Issue.facility_id.in_(customer_facility_ids),
|
||||
Area.facility_id.in_(customer_facility_ids)
|
||||
)
|
||||
)
|
||||
if is_inspector and not inspector_facility_ids:
|
||||
all_open_issues = []
|
||||
elif is_customer and not customer_facility_ids:
|
||||
all_open_issues = []
|
||||
else:
|
||||
all_open_issues = sla_q.all()
|
||||
sla_breached = sum(1 for i in all_open_issues if sla_status(i) == 'breached')
|
||||
sla_at_risk = sum(1 for i in all_open_issues if sla_status(i) == 'at_risk')
|
||||
|
||||
# ── Issues opened today ───────────────────────────────────────────────────
|
||||
from app.models.facility import Area as _AreaT
|
||||
opened_today_q = Issue.query.outerjoin(_AreaT, Issue.area_id == _AreaT.id).filter(
|
||||
Issue.reported_at >= today_start,
|
||||
Issue.reported_at < today_end,
|
||||
)
|
||||
if is_inspector:
|
||||
if not inspector_facility_ids:
|
||||
opened_today_q = opened_today_q.filter(False)
|
||||
else:
|
||||
opened_today_q = opened_today_q.filter(db.or_(
|
||||
Issue.facility_id.in_(inspector_facility_ids),
|
||||
_AreaT.facility_id.in_(inspector_facility_ids),
|
||||
))
|
||||
elif is_customer:
|
||||
if not customer_facility_ids:
|
||||
opened_today_q = opened_today_q.filter(False)
|
||||
else:
|
||||
opened_today_q = opened_today_q.filter(db.or_(
|
||||
Issue.facility_id.in_(customer_facility_ids),
|
||||
_AreaT.facility_id.in_(customer_facility_ids),
|
||||
))
|
||||
issues_opened_today = opened_today_q.count()
|
||||
|
||||
# ── Pending verification ──────────────────────────────────────────────────
|
||||
from app.models.facility import Area as _AreaV
|
||||
pv_q = Issue.query.outerjoin(_AreaV, Issue.area_id == _AreaV.id).filter(
|
||||
Issue.status == 'pending_verification',
|
||||
)
|
||||
if is_inspector:
|
||||
if not inspector_facility_ids:
|
||||
pv_q = pv_q.filter(False)
|
||||
else:
|
||||
pv_q = pv_q.filter(db.or_(
|
||||
Issue.facility_id.in_(inspector_facility_ids),
|
||||
_AreaV.facility_id.in_(inspector_facility_ids),
|
||||
))
|
||||
elif is_customer:
|
||||
if not customer_facility_ids:
|
||||
pv_q = pv_q.filter(False)
|
||||
else:
|
||||
pv_q = pv_q.filter(db.or_(
|
||||
Issue.facility_id.in_(customer_facility_ids),
|
||||
_AreaV.facility_id.in_(customer_facility_ids),
|
||||
))
|
||||
pending_verification = pv_q.count()
|
||||
|
||||
# ── Stale in-progress inspections (started > 24h ago, not yet submitted) ──
|
||||
stale_cutoff = now - timedelta(hours=24)
|
||||
stale_q = Inspection.query.filter(
|
||||
Inspection.status == 'in_progress',
|
||||
Inspection.inspection_date < stale_cutoff,
|
||||
)
|
||||
if is_inspector:
|
||||
if not inspector_facility_ids:
|
||||
stale_q = stale_q.filter(False)
|
||||
else:
|
||||
stale_q = stale_q.filter(
|
||||
Inspection.facility_id.in_(inspector_facility_ids),
|
||||
Inspection.inspector_id == current_user.id,
|
||||
)
|
||||
elif is_customer:
|
||||
if customer_facility_ids:
|
||||
stale_q = stale_q.filter(Inspection.facility_id.in_(customer_facility_ids))
|
||||
else:
|
||||
stale_q = stale_q.filter(False)
|
||||
stale_in_progress = stale_q.count()
|
||||
|
||||
# ── Unassigned open issues ────────────────────────────────────────────────
|
||||
from app.models.facility import Area as _AreaU
|
||||
unassigned_q = Issue.query.outerjoin(_AreaU, Issue.area_id == _AreaU.id).filter(
|
||||
Issue.status.in_(['open', 'in_progress']),
|
||||
Issue.assigned_to.is_(None),
|
||||
)
|
||||
if is_inspector:
|
||||
if not inspector_facility_ids:
|
||||
unassigned_q = unassigned_q.filter(False)
|
||||
else:
|
||||
unassigned_q = unassigned_q.filter(db.or_(
|
||||
Issue.facility_id.in_(inspector_facility_ids),
|
||||
_AreaU.facility_id.in_(inspector_facility_ids),
|
||||
))
|
||||
elif is_customer:
|
||||
unassigned_q = unassigned_q.filter(False) # not relevant for customers
|
||||
unassigned_open = unassigned_q.count()
|
||||
|
||||
# ── Inspector activity today (admin / director / PM only) ─────────────────
|
||||
inspector_activity = []
|
||||
if is_privileged or is_project_manager:
|
||||
active_inspectors = (
|
||||
User.query
|
||||
.filter_by(role='inspector', active=True)
|
||||
.order_by(User.full_name, User.username)
|
||||
.all()
|
||||
)
|
||||
today_counts = dict(
|
||||
db.session.query(
|
||||
Inspection.inspector_id,
|
||||
func.count(Inspection.id),
|
||||
)
|
||||
.filter(
|
||||
Inspection.status == 'completed',
|
||||
Inspection.inspection_date >= today_start,
|
||||
Inspection.inspection_date < today_end,
|
||||
)
|
||||
.group_by(Inspection.inspector_id)
|
||||
.all()
|
||||
)
|
||||
inspector_activity = sorted(
|
||||
[{'name': u.display_name, 'count': today_counts.get(u.id, 0)}
|
||||
for u in active_inspectors],
|
||||
key=lambda x: (-x['count'], x['name']),
|
||||
)
|
||||
|
||||
# ── My open issues (inspector dashboard widget) ───────────────────────────
|
||||
# Issues assigned to the current inspector that are not yet resolved,
|
||||
# ordered by SLA urgency (breached first, then at-risk, then ok).
|
||||
my_issues = []
|
||||
if is_inspector:
|
||||
my_issues = (
|
||||
Issue.query
|
||||
.filter(
|
||||
Issue.assigned_to == current_user.id,
|
||||
Issue.status.in_(['open', 'in_progress']),
|
||||
)
|
||||
.order_by(Issue.reported_at.asc())
|
||||
.limit(10)
|
||||
.all()
|
||||
)
|
||||
|
||||
return render_template(
|
||||
'dashboard.html',
|
||||
today_inspections = today_inspections,
|
||||
completed_today = completed_today,
|
||||
open_issues = open_issues,
|
||||
severity_breakdown = severity_breakdown,
|
||||
resolved_today = resolved_today,
|
||||
pending_followups = pending_followups,
|
||||
issues_opened_today = issues_opened_today,
|
||||
pending_verification = pending_verification,
|
||||
stale_in_progress = stale_in_progress,
|
||||
unassigned_open = unassigned_open,
|
||||
inspector_activity = inspector_activity,
|
||||
recent_inspections = recent_inspections,
|
||||
total_facilities = total_facilities,
|
||||
total_templates = total_templates,
|
||||
total_users = total_users,
|
||||
sla_breached = sla_breached,
|
||||
sla_at_risk = sla_at_risk,
|
||||
customer_facilities = customer_facilities,
|
||||
my_issues = my_issues,
|
||||
today_str = now.strftime('%Y-%m-%d'),
|
||||
)
|
||||
|
||||
|
||||
# ── AJAX: facility score trend ────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/facility-trend')
|
||||
@login_required
|
||||
def facility_trend():
|
||||
"""Return daily avg-score data for a single facility over N days.
|
||||
|
||||
Query params:
|
||||
facility_id (int, required)
|
||||
days (int, default 30 — allowed: 30, 60, 90)
|
||||
|
||||
Response JSON:
|
||||
{ labels: ['2026-03-01', ...], data: [85.2, ...], facility: 'Name' }
|
||||
"""
|
||||
from flask import jsonify, request as req
|
||||
|
||||
facility_id = req.args.get('facility_id', type=int)
|
||||
days = req.args.get('days', 30, type=int)
|
||||
if days not in (30, 60, 90):
|
||||
days = 30
|
||||
|
||||
if not facility_id:
|
||||
return jsonify({'labels': [], 'data': [], 'facility': ''})
|
||||
|
||||
# Scope check for customer users
|
||||
if current_user.role == 'customer':
|
||||
cids = get_customer_scope(current_user) or []
|
||||
if facility_id not in cids:
|
||||
return jsonify({'labels': [], 'data': [], 'facility': ''}), 403
|
||||
|
||||
facility = db.session.get(Facility, facility_id)
|
||||
if not facility:
|
||||
return jsonify({'labels': [], 'data': [], 'facility': ''})
|
||||
|
||||
start = now_eastern() - timedelta(days=days)
|
||||
|
||||
rows = (
|
||||
db.session.query(
|
||||
func.date(Inspection.inspection_date).label('day'),
|
||||
func.avg(Inspection.overall_score).label('avg'),
|
||||
)
|
||||
.filter(
|
||||
Inspection.facility_id == facility_id,
|
||||
Inspection.status == 'completed',
|
||||
Inspection.overall_score.isnot(None),
|
||||
Inspection.inspection_date >= start,
|
||||
)
|
||||
.group_by(func.date(Inspection.inspection_date))
|
||||
.order_by(func.date(Inspection.inspection_date))
|
||||
.all()
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'labels': [str(r.day) for r in rows],
|
||||
'data': [round(float(r.avg), 2) for r in rows],
|
||||
'facility': facility.name,
|
||||
})
|
||||
|
||||
|
||||
@bp.route('/support')
|
||||
def support():
|
||||
"""Public support page — no login required. Used as App Store Connect Support URL."""
|
||||
return render_template('support.html', current_year=datetime.utcnow().year)
|
||||
@@ -0,0 +1,238 @@
|
||||
import logging
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.facility import Facility, Area
|
||||
from app.models.project import Project
|
||||
from app.utils.forms import FacilityForm, AreaForm
|
||||
from app.utils.decorators import supervisor_required, admin_required
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
|
||||
from app.utils.scope import get_customer_scope, get_inspector_scope
|
||||
|
||||
bp = Blueprint('facilities', __name__, url_prefix='/facilities')
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
def list_facilities():
|
||||
if current_user.role == 'customer':
|
||||
cids = get_customer_scope(current_user) or []
|
||||
facilities = Facility.query.filter(
|
||||
Facility.id.in_(cids), Facility.active == True
|
||||
).order_by(Facility.name).all()
|
||||
elif current_user.role == 'inspector':
|
||||
fids = get_inspector_scope(current_user) or []
|
||||
facilities = Facility.query.filter(
|
||||
Facility.id.in_(fids), Facility.active == True
|
||||
).order_by(Facility.name).all()
|
||||
else:
|
||||
facilities = Facility.query.order_by(Facility.name).all()
|
||||
|
||||
# Group facilities by Contract (Project) for the collapsible list view.
|
||||
# Facilities with no contract are collected under key '__none__' and
|
||||
# rendered last as "No Contract Assigned".
|
||||
from collections import OrderedDict
|
||||
grouped = OrderedDict()
|
||||
ungrouped = []
|
||||
for f in facilities:
|
||||
if f.project:
|
||||
key = f.project.name
|
||||
grouped.setdefault(key, {'project': f.project, 'facilities': []})
|
||||
grouped[key]['facilities'].append(f)
|
||||
else:
|
||||
ungrouped.append(f)
|
||||
|
||||
grouped = OrderedDict(sorted(grouped.items()))
|
||||
if ungrouped:
|
||||
grouped['__none__'] = {'project': None, 'facilities': ungrouped}
|
||||
|
||||
logger.info('FACILITIES | list | user=%s | total=%s | groups=%s',
|
||||
current_user.username, len(facilities), len(grouped))
|
||||
return render_template('facilities/list.html', facilities=facilities, grouped=grouped)
|
||||
|
||||
@bp.route('/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def create_facility():
|
||||
form = FacilityForm()
|
||||
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]
|
||||
|
||||
if form.validate_on_submit():
|
||||
project_id = form.project_id.data if form.project_id.data else None
|
||||
facility = Facility(
|
||||
name=form.name.data,
|
||||
address=form.address.data,
|
||||
contact_person=form.contact_person.data,
|
||||
contact_phone=form.contact_phone.data,
|
||||
project_id=project_id if project_id else None,
|
||||
active=form.active.data
|
||||
)
|
||||
|
||||
db.session.add(facility)
|
||||
db.session.commit()
|
||||
logger.info('FACILITIES | create | user=%s | facility_id=%s name=%r',
|
||||
current_user.username, facility.id, facility.name)
|
||||
log_action(ACTION_CREATE, 'Facility', facility.id, facility.name,
|
||||
f'contact={facility.contact_person or ""}; project_id={facility.project_id}; active={facility.active}')
|
||||
flash(f'Facility "{facility.name}" created successfully.', 'success')
|
||||
return redirect(url_for('facilities.view_facility', facility_id=facility.id))
|
||||
|
||||
return render_template('facilities/form.html', form=form, title='Create Facility')
|
||||
|
||||
@bp.route('/<int:facility_id>')
|
||||
@login_required
|
||||
def view_facility(facility_id):
|
||||
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:
|
||||
flash('Access denied.', 'danger')
|
||||
return redirect(url_for('facilities.list_facilities'))
|
||||
areas = facility.areas.order_by(Area.name).all()
|
||||
return render_template('facilities/view.html', facility=facility, areas=areas)
|
||||
|
||||
@bp.route('/<int:facility_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def edit_facility(facility_id):
|
||||
facility = db.session.get(Facility, facility_id)
|
||||
if facility is None:
|
||||
abort(404)
|
||||
form = FacilityForm(obj=facility)
|
||||
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]
|
||||
|
||||
if form.validate_on_submit():
|
||||
project_id = form.project_id.data if form.project_id.data else None
|
||||
facility.name = form.name.data
|
||||
facility.address = form.address.data
|
||||
facility.contact_person = form.contact_person.data
|
||||
facility.contact_phone = form.contact_phone.data
|
||||
facility.project_id = project_id if project_id else None
|
||||
facility.active = form.active.data
|
||||
|
||||
db.session.commit()
|
||||
logger.info('FACILITIES | edit | user=%s | facility_id=%s name=%r',
|
||||
current_user.username, facility.id, facility.name)
|
||||
log_action(ACTION_UPDATE, 'Facility', facility.id, facility.name,
|
||||
f'project_id={facility.project_id}; active={facility.active}')
|
||||
flash(f'Facility "{facility.name}" updated successfully.', 'success')
|
||||
return redirect(url_for('facilities.view_facility', facility_id=facility.id))
|
||||
|
||||
return render_template('facilities/form.html', form=form, facility=facility, title='Edit Facility')
|
||||
|
||||
@bp.route('/<int:facility_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_facility(facility_id):
|
||||
facility = db.session.get(Facility, facility_id)
|
||||
if facility is None:
|
||||
abort(404)
|
||||
|
||||
if facility.inspections.count() > 0:
|
||||
flash(f'Cannot delete "{facility.name}" — it has existing inspection records.', 'danger')
|
||||
return redirect(url_for('facilities.view_facility', facility_id=facility.id))
|
||||
|
||||
facility_name = facility.name
|
||||
facility_id_snap = facility.id
|
||||
db.session.delete(facility)
|
||||
db.session.commit()
|
||||
logger.info('FACILITIES | delete | user=%s | facility_id=%s name=%r',
|
||||
current_user.username, facility_id_snap, facility_name)
|
||||
log_action(ACTION_DELETE, 'Facility', facility_id_snap, facility_name)
|
||||
flash(f'Facility "{facility_name}" has been permanently deleted.', 'success')
|
||||
return redirect(url_for('facilities.list_facilities'))
|
||||
|
||||
# Area Management Routes
|
||||
|
||||
@bp.route('/<int:facility_id>/areas/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def create_area(facility_id):
|
||||
facility = db.session.get(Facility, facility_id)
|
||||
if facility is None:
|
||||
abort(404)
|
||||
form = AreaForm()
|
||||
form.facility_id.choices = [(facility.id, facility.name)]
|
||||
form.facility_id.data = facility.id
|
||||
|
||||
if form.validate_on_submit():
|
||||
area = Area(
|
||||
name=form.name.data,
|
||||
area_type=form.area_type.data,
|
||||
facility_id=facility.id
|
||||
)
|
||||
|
||||
db.session.add(area)
|
||||
db.session.commit()
|
||||
logger.info('FACILITIES | create_area | user=%s | area_id=%s name=%r facility=%r',
|
||||
current_user.username, area.id, area.name, facility.name)
|
||||
log_action(ACTION_CREATE, 'Area', area.id, area.name,
|
||||
f'facility={facility.name}; type={area.area_type or ""}')
|
||||
flash(f'Area "{area.name}" created successfully.', 'success')
|
||||
return redirect(url_for('facilities.view_facility', facility_id=facility.id))
|
||||
|
||||
return render_template('facilities/area_form.html', form=form, facility=facility, title='Create Area')
|
||||
|
||||
@bp.route('/areas/<int:area_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def edit_area(area_id):
|
||||
area = db.session.get(Area, area_id)
|
||||
if area is None:
|
||||
abort(404)
|
||||
form = AreaForm(obj=area)
|
||||
|
||||
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
||||
form.facility_id.choices = [(f.id, f.name) for f in facilities]
|
||||
|
||||
if form.validate_on_submit():
|
||||
area.name = form.name.data
|
||||
area.area_type = form.area_type.data
|
||||
area.facility_id = form.facility_id.data
|
||||
|
||||
db.session.commit()
|
||||
logger.info('FACILITIES | edit_area | user=%s | area_id=%s name=%r',
|
||||
current_user.username, area.id, area.name)
|
||||
log_action(ACTION_UPDATE, 'Area', area.id, area.name,
|
||||
f'facility_id={area.facility_id}; type={area.area_type or ""}')
|
||||
flash(f'Area "{area.name}" updated successfully.', 'success')
|
||||
return redirect(url_for('facilities.view_facility', facility_id=area.facility_id))
|
||||
|
||||
return render_template('facilities/area_form.html', form=form, area=area, facility=area.facility, title='Edit Area')
|
||||
|
||||
@bp.route('/areas/<int:area_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def delete_area(area_id):
|
||||
area = db.session.get(Area, area_id)
|
||||
if area is None:
|
||||
abort(404)
|
||||
facility_id = area.facility_id
|
||||
|
||||
if area.inspections.count() > 0:
|
||||
flash('Cannot delete area with existing inspections.', 'danger')
|
||||
return redirect(url_for('facilities.view_facility', facility_id=facility_id))
|
||||
|
||||
issue_count = area.issues.count()
|
||||
if issue_count > 0:
|
||||
flash(
|
||||
f'Cannot delete area "{area.name}" — it has {issue_count} issue record(s) on file. '
|
||||
f'Resolve or reassign those issues first.',
|
||||
'danger'
|
||||
)
|
||||
return redirect(url_for('facilities.view_facility', facility_id=facility_id))
|
||||
|
||||
area_name = area.name
|
||||
area_id_snap = area.id
|
||||
db.session.delete(area)
|
||||
db.session.commit()
|
||||
logger.info('FACILITIES | delete_area | user=%s | area_id=%s name=%r',
|
||||
current_user.username, area_id_snap, area_name)
|
||||
log_action(ACTION_DELETE, 'Area', area_id_snap, area_name)
|
||||
flash(f'Area "{area_name}" deleted successfully.', 'success')
|
||||
return redirect(url_for('facilities.view_facility', facility_id=facility_id))
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,308 @@
|
||||
# app/routes/notifications.py
|
||||
import logging
|
||||
from flask import (Blueprint, jsonify, request, abort,
|
||||
render_template, redirect, url_for, flash, current_app)
|
||||
from flask_login import login_required, current_user
|
||||
from app import db, csrf
|
||||
from app.models.notification import (
|
||||
Notification, NotificationPreference, ALL_EVENT_TYPES
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('notifications', __name__, url_prefix='/notifications')
|
||||
|
||||
|
||||
# ── Bell feed (navbar dropdown) ───────────────────────────────────────────────
|
||||
|
||||
@bp.route('/feed')
|
||||
@login_required
|
||||
def feed():
|
||||
"""Return the 20 most recent notifications for the current user as JSON."""
|
||||
notifs = (
|
||||
Notification.query
|
||||
.filter_by(user_id=current_user.id)
|
||||
.order_by(Notification.created_at.desc())
|
||||
.limit(20)
|
||||
.all()
|
||||
)
|
||||
unread_count = Notification.query.filter_by(
|
||||
user_id=current_user.id, is_read=False
|
||||
).count()
|
||||
|
||||
items = []
|
||||
for n in notifs:
|
||||
items.append({
|
||||
'id': n.id,
|
||||
'title': n.title,
|
||||
'body': n.body,
|
||||
'link': n.link,
|
||||
'is_read': n.is_read,
|
||||
'created_at': n.created_at.strftime('%b %d, %Y %I:%M %p'),
|
||||
})
|
||||
|
||||
return jsonify({'notifications': items, 'unread_count': unread_count})
|
||||
|
||||
|
||||
# ── Full notification history page ────────────────────────────────────────────
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
"""Full paginated notification history with read/unread filter."""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
filter_read = request.args.get('filter', 'all') # 'all' | 'unread' | 'read'
|
||||
|
||||
q = Notification.query.filter_by(user_id=current_user.id)
|
||||
|
||||
if filter_read == 'unread':
|
||||
q = q.filter_by(is_read=False)
|
||||
elif filter_read == 'read':
|
||||
q = q.filter_by(is_read=True)
|
||||
|
||||
notifications = q.order_by(Notification.created_at.desc()).paginate(
|
||||
page=page, per_page=25, error_out=False
|
||||
)
|
||||
unread_count = Notification.query.filter_by(
|
||||
user_id=current_user.id, is_read=False
|
||||
).count()
|
||||
|
||||
return render_template(
|
||||
'notifications/index.html',
|
||||
notifications=notifications,
|
||||
filter_read=filter_read,
|
||||
unread_count=unread_count,
|
||||
)
|
||||
|
||||
|
||||
# ── Mark single notification read ─────────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:notif_id>/mark-read', methods=['POST'])
|
||||
@login_required
|
||||
def mark_read(notif_id):
|
||||
notif = db.session.get(Notification, notif_id)
|
||||
if notif is None:
|
||||
abort(404)
|
||||
if notif.user_id != current_user.id:
|
||||
abort(403)
|
||||
notif.is_read = True
|
||||
db.session.commit()
|
||||
logger.info(
|
||||
'NOTIFICATION READ | id=%s | user=%s',
|
||||
notif_id, current_user.username,
|
||||
)
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ── Mark all read ─────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/mark-all-read', methods=['POST'])
|
||||
@login_required
|
||||
def mark_all_read():
|
||||
updated = (
|
||||
Notification.query
|
||||
.filter_by(user_id=current_user.id, is_read=False)
|
||||
.update({'is_read': True})
|
||||
)
|
||||
db.session.commit()
|
||||
logger.info(
|
||||
'NOTIFICATIONS ALL READ | user=%s | count=%s',
|
||||
current_user.username, updated,
|
||||
)
|
||||
|
||||
# Support both AJAX (returns JSON) and form POST (redirects to index)
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or \
|
||||
request.content_type == 'application/json':
|
||||
return jsonify({'ok': True, 'marked': updated})
|
||||
return redirect(url_for('notifications.index'))
|
||||
|
||||
|
||||
# ── Notification preferences ──────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/preferences', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def preferences():
|
||||
"""Display and save per-event notification preferences."""
|
||||
if request.method == 'POST':
|
||||
for event_type in ALL_EVENT_TYPES:
|
||||
pref = NotificationPreference.query.filter_by(
|
||||
user_id=current_user.id,
|
||||
event_type=event_type,
|
||||
).first()
|
||||
|
||||
if pref is None:
|
||||
pref = NotificationPreference(
|
||||
user_id=current_user.id,
|
||||
event_type=event_type,
|
||||
)
|
||||
db.session.add(pref)
|
||||
|
||||
pref.email_enabled = bool(request.form.get(f'email_{event_type}'))
|
||||
pref.digest_mode = bool(request.form.get(f'digest_{event_type}'))
|
||||
pref.digest_frequency = request.form.get(f'freq_{event_type}', 'daily')
|
||||
|
||||
# Guard: digest_mode only meaningful when email is enabled
|
||||
if not pref.email_enabled:
|
||||
pref.digest_mode = False
|
||||
|
||||
db.session.commit()
|
||||
logger.info(
|
||||
'NOTIFICATION PREFERENCES SAVED | user=%s',
|
||||
current_user.username,
|
||||
)
|
||||
flash('Notification preferences saved.', 'success')
|
||||
return redirect(url_for('notifications.preferences'))
|
||||
|
||||
# Build a dict keyed by event_type for easy template access
|
||||
prefs_map = {}
|
||||
for pref in NotificationPreference.query.filter_by(user_id=current_user.id).all():
|
||||
prefs_map[pref.event_type] = pref
|
||||
|
||||
return render_template(
|
||||
'notifications/preferences.html',
|
||||
event_types=ALL_EVENT_TYPES,
|
||||
prefs_map=prefs_map,
|
||||
)
|
||||
|
||||
|
||||
# ── Digest trigger (called by cron) ───────────────────────────────────────────
|
||||
|
||||
@bp.route('/send-digest', methods=['POST'])
|
||||
@csrf.exempt
|
||||
def send_digest():
|
||||
"""Trigger digest email delivery. Protected by a shared secret token.
|
||||
|
||||
Called by a cron job, e.g.:
|
||||
# Hourly digest
|
||||
0 * * * * curl -s -X POST https://yourdomain.com/notifications/send-digest \
|
||||
-d "token=YOUR_DIGEST_SECRET&frequency=hourly"
|
||||
|
||||
# Daily digest at 07:00
|
||||
0 7 * * * curl -s -X POST https://yourdomain.com/notifications/send-digest \
|
||||
-d "token=YOUR_DIGEST_SECRET&frequency=daily"
|
||||
"""
|
||||
token = request.form.get('token') or request.args.get('token')
|
||||
frequency = request.form.get('frequency', 'daily')
|
||||
|
||||
expected = current_app.config.get('DIGEST_SECRET')
|
||||
if not expected or token != expected:
|
||||
logger.warning('DIGEST TRIGGER REJECTED | bad or missing token')
|
||||
abort(403)
|
||||
|
||||
if frequency not in ('hourly', 'daily'):
|
||||
abort(400)
|
||||
|
||||
from app.utils.notifications import send_pending_digests
|
||||
sent = send_pending_digests(frequency=frequency)
|
||||
|
||||
logger.info('DIGEST TRIGGERED | frequency=%s | sent=%s', frequency, sent)
|
||||
return jsonify({'ok': True, 'sent': sent, 'frequency': frequency})
|
||||
|
||||
# ── SLA alert trigger (called by cron) ────────────────────────────────────────
|
||||
|
||||
@bp.route('/check-sla', methods=['POST'])
|
||||
@csrf.exempt
|
||||
def check_sla():
|
||||
"""Scan all open issues for SLA breaches and dispatch alerts.
|
||||
|
||||
Protected by the same DIGEST_SECRET token used for digest delivery.
|
||||
Recommended cron schedule — every 30 minutes is sufficient for most
|
||||
deployments; adjust based on your shortest SLA threshold (critical = 4h):
|
||||
|
||||
*/30 * * * * curl -s -X POST https://yourdomain.com/notifications/check-sla \\
|
||||
-d "token=YOUR_DIGEST_SECRET"
|
||||
"""
|
||||
token = request.form.get('token') or request.args.get('token')
|
||||
expected = current_app.config.get('DIGEST_SECRET')
|
||||
|
||||
if not expected or token != expected:
|
||||
logger.warning('SLA CHECK REJECTED | bad or missing token')
|
||||
abort(403)
|
||||
|
||||
from app.utils.sla import send_sla_alerts
|
||||
sent = send_sla_alerts()
|
||||
|
||||
logger.info('SLA CHECK TRIGGERED | notifications_sent=%s', sent)
|
||||
return jsonify({'ok': True, 'notifications_sent': sent})
|
||||
|
||||
|
||||
# ── Expired token cleanup (called by cron) ────────────────────────────────────
|
||||
|
||||
@bp.route('/cleanup-tokens', methods=['POST'])
|
||||
@csrf.exempt
|
||||
def cleanup_tokens():
|
||||
"""Purge expired and revoked refresh tokens from api_refresh_tokens.
|
||||
|
||||
Safe to run frequently — only deletes rows where expires_at has passed
|
||||
OR revoked=True. Keeps the table lean without touching live sessions.
|
||||
|
||||
Recommended cron schedule — nightly is sufficient:
|
||||
|
||||
0 3 * * * curl -s -X POST https://yourdomain.com/notifications/cleanup-tokens \\
|
||||
-d "token=YOUR_DIGEST_SECRET"
|
||||
"""
|
||||
token = request.form.get('token') or request.args.get('token')
|
||||
expected = current_app.config.get('DIGEST_SECRET')
|
||||
|
||||
if not expected or token != expected:
|
||||
logger.warning('TOKEN CLEANUP REJECTED | bad or missing token')
|
||||
abort(403)
|
||||
|
||||
from app.models.api_token import RefreshToken
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
now = now_eastern()
|
||||
deleted = (
|
||||
RefreshToken.query
|
||||
.filter(
|
||||
db.or_(
|
||||
RefreshToken.expires_at < now,
|
||||
RefreshToken.revoked == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
logger.info('TOKEN CLEANUP | deleted=%s expired/revoked rows', deleted)
|
||||
return jsonify({'ok': True, 'deleted': deleted})
|
||||
|
||||
|
||||
# ── Score trend alert trigger (called by cron) ────────────────────────────────
|
||||
|
||||
@bp.route('/check-score-trends', methods=['POST'])
|
||||
@csrf.exempt
|
||||
def check_score_trends():
|
||||
"""Scan facility score trends and dispatch alerts for significant drops.
|
||||
|
||||
Compares each active facility's avg inspection score for the last 30 days
|
||||
against the prior 30-day period. Alerts fire when the drop exceeds the
|
||||
configured threshold (default: 5 percentage points).
|
||||
|
||||
Protected by the same DIGEST_SECRET token used by the other cron endpoints.
|
||||
|
||||
Recommended cron schedule — once per day is sufficient:
|
||||
|
||||
0 8 * * * curl -s -X POST https://yourdomain.com/notifications/check-score-trends \\
|
||||
-d "token=YOUR_DIGEST_SECRET"
|
||||
|
||||
Optional param:
|
||||
threshold=<float> Override the default 5.0-point drop threshold.
|
||||
"""
|
||||
token = request.form.get('token') or request.args.get('token')
|
||||
expected = current_app.config.get('DIGEST_SECRET')
|
||||
|
||||
if not expected or token != expected:
|
||||
logger.warning('SCORE TREND CHECK REJECTED | bad or missing token')
|
||||
abort(403)
|
||||
|
||||
threshold = request.form.get('threshold', type=float) or None
|
||||
|
||||
from app.utils.sla import send_score_alerts
|
||||
kwargs = {}
|
||||
if threshold is not None:
|
||||
kwargs['threshold'] = threshold
|
||||
sent = send_score_alerts(**kwargs)
|
||||
|
||||
logger.info('SCORE TREND CHECK TRIGGERED | alerts_sent=%s', sent)
|
||||
return jsonify({'ok': True, 'alerts_sent': sent})
|
||||
@@ -0,0 +1,668 @@
|
||||
"""
|
||||
app/routes/projects.py
|
||||
----------------------
|
||||
Project management routes.
|
||||
|
||||
Access matrix:
|
||||
- List / view : admin, supervisor, project_manager
|
||||
- Create / edit / delete : admin, supervisor
|
||||
- Customer assignment management : admin
|
||||
"""
|
||||
|
||||
import logging
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, Response
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.project import Project, CustomerAssignment
|
||||
from app.models.facility import Facility
|
||||
from app.models.user import User
|
||||
from app.utils.forms import ProjectForm, CustomerAssignmentForm
|
||||
from app.utils.decorators import admin_required, supervisor_required, project_manager_required
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('projects', __name__, url_prefix='/projects')
|
||||
|
||||
|
||||
# ── List ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
@project_manager_required
|
||||
def index():
|
||||
projects = Project.query.order_by(Project.name).all()
|
||||
return render_template('projects/list.html', projects=projects)
|
||||
|
||||
|
||||
# ── Create ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def create():
|
||||
form = ProjectForm()
|
||||
# Populate project_manager choices: users with role project_manager
|
||||
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]
|
||||
|
||||
if form.validate_on_submit():
|
||||
pm_id = form.project_manager_id.data or None
|
||||
project = Project(
|
||||
name=form.name.data,
|
||||
description=form.description.data,
|
||||
project_manager_id=pm_id if pm_id else None,
|
||||
active=form.active.data,
|
||||
)
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
logger.info('PROJECTS | create | user=%s project_id=%s name=%s',
|
||||
current_user.username, project.id, project.name)
|
||||
log_action(ACTION_CREATE, 'Project', project.id, project.name,
|
||||
f'pm_id={pm_id}; active={project.active}')
|
||||
flash(f'Contract "{project.name}" created successfully.', 'success')
|
||||
return redirect(url_for('projects.view', project_id=project.id))
|
||||
|
||||
return render_template('projects/form.html', form=form, title='Create Contract')
|
||||
|
||||
|
||||
# ── View ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:project_id>')
|
||||
@login_required
|
||||
@project_manager_required
|
||||
def view(project_id):
|
||||
project = db.session.get(Project, project_id)
|
||||
if project is None:
|
||||
abort(404)
|
||||
facilities = project.facilities.order_by(Facility.name).all()
|
||||
assignments = (
|
||||
CustomerAssignment.query
|
||||
.filter_by(project_id=project_id)
|
||||
.join(User, CustomerAssignment.user_id == User.id)
|
||||
.order_by(User.username)
|
||||
.all()
|
||||
)
|
||||
return render_template(
|
||||
'projects/view.html',
|
||||
project=project,
|
||||
facilities=facilities,
|
||||
assignments=assignments,
|
||||
)
|
||||
|
||||
|
||||
# ── Edit ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:project_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def edit(project_id):
|
||||
project = db.session.get(Project, project_id)
|
||||
if project is None:
|
||||
abort(404)
|
||||
form = ProjectForm(obj=project)
|
||||
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]
|
||||
|
||||
if form.validate_on_submit():
|
||||
pm_id = form.project_manager_id.data or None
|
||||
project.name = form.name.data
|
||||
project.description = form.description.data
|
||||
project.project_manager_id = pm_id if pm_id else None
|
||||
project.active = form.active.data
|
||||
db.session.commit()
|
||||
logger.info('PROJECTS | edit | user=%s project_id=%s name=%s',
|
||||
current_user.username, project.id, project.name)
|
||||
log_action(ACTION_UPDATE, 'Project', project.id, project.name,
|
||||
f'pm_id={project.project_manager_id}; active={project.active}')
|
||||
flash(f'Contract "{project.name}" updated successfully.', 'success')
|
||||
return redirect(url_for('projects.view', project_id=project.id))
|
||||
|
||||
return render_template('projects/form.html', form=form, project=project, title='Edit Contract')
|
||||
|
||||
|
||||
# ── Delete ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:project_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete(project_id):
|
||||
project = db.session.get(Project, project_id)
|
||||
if project is None:
|
||||
abort(404)
|
||||
|
||||
if project.facilities.count() > 0:
|
||||
flash(f'Cannot delete "{project.name}" — it has linked facilities. '
|
||||
'Reassign or remove those facilities first.', 'danger')
|
||||
return redirect(url_for('projects.view', project_id=project_id))
|
||||
|
||||
project_name = project.name
|
||||
project_id_snap = project.id
|
||||
db.session.delete(project)
|
||||
db.session.commit()
|
||||
logger.info('PROJECTS | delete | user=%s project_id=%s name=%s',
|
||||
current_user.username, project_id_snap, project_name)
|
||||
log_action(ACTION_DELETE, 'Project', project_id_snap, project_name)
|
||||
flash(f'Contract "{project_name}" deleted successfully.', 'success')
|
||||
return redirect(url_for('projects.index'))
|
||||
|
||||
|
||||
# ── Customer Assignment — Add ─────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:project_id>/assignments/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def add_assignment(project_id):
|
||||
project = db.session.get(Project, project_id)
|
||||
if project is None:
|
||||
abort(404)
|
||||
form = CustomerAssignmentForm()
|
||||
|
||||
# Customer users only
|
||||
customers = User.query.filter_by(role='customer', active=True).order_by(User.username).all()
|
||||
form.user_id.choices = [(u.id, f'{u.username} ({u.email})') for u in customers]
|
||||
|
||||
# Facilities belonging to this project
|
||||
project_facilities = project.facilities.order_by(Facility.name).all()
|
||||
form.facility_id.choices = [(0, '— All facilities in contract —')] + \
|
||||
[(f.id, f.name) for f in project_facilities]
|
||||
|
||||
if form.validate_on_submit():
|
||||
facility_id = form.facility_id.data if form.facility_id.data else None
|
||||
|
||||
# Guard against duplicate assignments
|
||||
existing = CustomerAssignment.query.filter_by(
|
||||
user_id=form.user_id.data,
|
||||
project_id=project_id,
|
||||
facility_id=facility_id,
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
flash('This customer assignment already exists.', 'warning')
|
||||
return redirect(url_for('projects.view', project_id=project_id))
|
||||
|
||||
assignment = CustomerAssignment(
|
||||
user_id=form.user_id.data,
|
||||
project_id=project_id,
|
||||
facility_id=facility_id,
|
||||
)
|
||||
db.session.add(assignment)
|
||||
db.session.commit()
|
||||
|
||||
user = db.session.get(User, form.user_id.data)
|
||||
scope_label = f'facility_id={facility_id}' if facility_id else 'all facilities'
|
||||
logger.info('PROJECTS | assignment_add | admin=%s customer=%s project_id=%s scope=%s',
|
||||
current_user.username, user.username, project_id, scope_label)
|
||||
log_action(ACTION_CREATE, 'CustomerAssignment', assignment.id,
|
||||
f'{user.username} → {project.name}',
|
||||
f'scope={scope_label}')
|
||||
flash(f'Customer "{user.username}" assigned to contract "{project.name}".', 'success')
|
||||
return redirect(url_for('projects.view', project_id=project_id))
|
||||
|
||||
return render_template(
|
||||
'projects/assignment_form.html',
|
||||
form=form,
|
||||
project=project,
|
||||
title='Add Customer Assignment',
|
||||
)
|
||||
|
||||
|
||||
# ── Customer Assignment — Remove ──────────────────────────────────────────────
|
||||
|
||||
@bp.route('/assignments/<int:assignment_id>/remove', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def remove_assignment(assignment_id):
|
||||
assignment = db.session.get(CustomerAssignment, assignment_id)
|
||||
if assignment is None:
|
||||
abort(404)
|
||||
project_id = assignment.project_id
|
||||
project = db.session.get(Project, project_id)
|
||||
if project is None:
|
||||
abort(404)
|
||||
user = db.session.get(User, assignment.user_id)
|
||||
|
||||
username = user.username if user else f'user_id={assignment.user_id}'
|
||||
assignment_id_snap = assignment.id
|
||||
|
||||
db.session.delete(assignment)
|
||||
db.session.commit()
|
||||
|
||||
logger.info('PROJECTS | assignment_remove | admin=%s customer=%s project_id=%s',
|
||||
current_user.username, username, project_id)
|
||||
log_action(ACTION_DELETE, 'CustomerAssignment', assignment_id_snap,
|
||||
f'{username} → {project.name}')
|
||||
flash(f'Assignment for "{username}" removed.', 'success')
|
||||
return redirect(url_for('projects.view', project_id=project_id))
|
||||
|
||||
|
||||
# ── Bulk Import — Excel template download ─────────────────────────────────────
|
||||
|
||||
@bp.route('/import/template')
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def import_template():
|
||||
"""Download a blank .xlsx showing the expected import format."""
|
||||
import io
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
|
||||
wb = Workbook()
|
||||
|
||||
# ── Sheet 1: Contracts ────────────────────────────────────────────────
|
||||
ws_c = wb.active
|
||||
ws_c.title = 'Contracts'
|
||||
hdr_fill = PatternFill('solid', start_color='1F4E79')
|
||||
hdr_font = Font(bold=True, color='FFFFFF')
|
||||
hdr_align = Alignment(horizontal='center', vertical='center')
|
||||
|
||||
contract_headers = ['contract_name', 'description', 'active']
|
||||
for col, h in enumerate(contract_headers, 1):
|
||||
cell = ws_c.cell(row=1, column=col, value=h)
|
||||
cell.font = hdr_font
|
||||
cell.fill = hdr_fill
|
||||
cell.alignment = hdr_align
|
||||
|
||||
# Example rows
|
||||
ws_c.append(['Acme Corp - Downtown', 'Main office complex cleaning contract', 'yes'])
|
||||
ws_c.append(['Acme Corp - Warehouse', '', 'yes'])
|
||||
|
||||
ws_c.column_dimensions['A'].width = 30
|
||||
ws_c.column_dimensions['B'].width = 40
|
||||
ws_c.column_dimensions['C'].width = 10
|
||||
|
||||
# ── Sheet 2: Facilities ───────────────────────────────────────────────
|
||||
ws_f = wb.create_sheet('Facilities')
|
||||
facility_headers = [
|
||||
'contract_name', 'facility_name', 'address',
|
||||
'contact_person', 'contact_phone', 'active',
|
||||
]
|
||||
for col, h in enumerate(facility_headers, 1):
|
||||
cell = ws_f.cell(row=1, column=col, value=h)
|
||||
cell.font = hdr_font
|
||||
cell.fill = hdr_fill
|
||||
cell.alignment = hdr_align
|
||||
|
||||
ws_f.append(['Acme Corp - Downtown', 'Tower A', '123 Main St, Suite 100', 'Jane Smith', '555-0101', 'yes'])
|
||||
ws_f.append(['Acme Corp - Downtown', 'Parking Garage', '123 Main St, Level B1', '', '', 'yes'])
|
||||
ws_f.append(['Acme Corp - Warehouse', 'Bay 1', '456 Industrial Blvd', 'Bob Jones', '555-0202', 'yes'])
|
||||
|
||||
ws_f.column_dimensions['A'].width = 30
|
||||
ws_f.column_dimensions['B'].width = 25
|
||||
ws_f.column_dimensions['C'].width = 35
|
||||
ws_f.column_dimensions['D'].width = 20
|
||||
ws_f.column_dimensions['E'].width = 15
|
||||
ws_f.column_dimensions['F'].width = 10
|
||||
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
buf.seek(0)
|
||||
return Response(
|
||||
buf.getvalue(),
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
headers={'Content-Disposition': 'attachment; filename="contract_facility_import_template.xlsx"'},
|
||||
)
|
||||
|
||||
|
||||
# ── Bulk Import — upload → preview → confirm ──────────────────────────────────
|
||||
|
||||
@bp.route('/import', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def bulk_import():
|
||||
"""Two-phase Excel import for Contracts (Projects) and Facilities.
|
||||
|
||||
Phase 1 (GET / POST with file):
|
||||
Parse and validate the workbook, return a preview. No DB writes.
|
||||
|
||||
Phase 2 (POST with confirmed=1):
|
||||
Write all validated rows to the database.
|
||||
|
||||
Excel format (two sheets)
|
||||
--------------------------
|
||||
Sheet "Contracts": contract_name*, description, active
|
||||
Sheet "Facilities": contract_name*, facility_name*, address,
|
||||
contact_person, contact_phone, active
|
||||
|
||||
* required columns.
|
||||
- "active" column: any of yes/true/1 → True; blank defaults to True.
|
||||
- Contracts that already exist (by name, case-insensitive) are reused,
|
||||
not duplicated.
|
||||
- Facilities that already exist (same name within same contract) are
|
||||
skipped and reported.
|
||||
"""
|
||||
import io, json
|
||||
from flask import session as _session
|
||||
|
||||
# ── Phase 2: commit ───────────────────────────────────────────────────
|
||||
if request.method == 'POST' and request.form.get('confirmed') == '1':
|
||||
rows_json = request.form.get('rows_json', '[]')
|
||||
try:
|
||||
rows = json.loads(rows_json)
|
||||
except Exception:
|
||||
flash('Import session expired. Please re-upload the file.', 'danger')
|
||||
return redirect(url_for('projects.bulk_import'))
|
||||
|
||||
created_contracts = 0
|
||||
reused_contracts = 0
|
||||
created_facilities = 0
|
||||
skipped_facilities = 0
|
||||
|
||||
# Cache contracts created/found in this batch
|
||||
contract_cache = {} # lower-name → Project
|
||||
|
||||
for row in rows:
|
||||
cname = row['contract_name']
|
||||
cdesc = row.get('description') or None
|
||||
c_active = row.get('contract_active', True)
|
||||
fname = row.get('facility_name') or None
|
||||
|
||||
# Get or create contract
|
||||
cache_key = cname.strip().lower()
|
||||
project = contract_cache.get(cache_key)
|
||||
if project is None:
|
||||
project = Project.query.filter(
|
||||
db.func.lower(Project.name) == cache_key
|
||||
).first()
|
||||
|
||||
if project is None:
|
||||
project = Project(
|
||||
name = cname.strip(),
|
||||
description = cdesc,
|
||||
active = c_active,
|
||||
)
|
||||
db.session.add(project)
|
||||
db.session.flush()
|
||||
contract_cache[cache_key] = project
|
||||
created_contracts += 1
|
||||
log_action(ACTION_CREATE, 'Project', project.id, project.name,
|
||||
f'active={c_active}; source=bulk_import')
|
||||
logger.info('BULK IMPORT | contract_created | name=%s by=%s',
|
||||
project.name, current_user.username)
|
||||
else:
|
||||
contract_cache[cache_key] = project
|
||||
reused_contracts += 1
|
||||
|
||||
# Create facility if present in this row
|
||||
if fname:
|
||||
faddr = row.get('address') or None
|
||||
fcp = row.get('contact_person') or None
|
||||
fphone = row.get('contact_phone') or None
|
||||
f_active = row.get('facility_active', True)
|
||||
|
||||
existing_fac = Facility.query.filter(
|
||||
Facility.project_id == project.id,
|
||||
db.func.lower(Facility.name) == fname.strip().lower(),
|
||||
).first()
|
||||
|
||||
if existing_fac:
|
||||
skipped_facilities += 1
|
||||
else:
|
||||
facility = Facility(
|
||||
name = fname.strip(),
|
||||
address = faddr,
|
||||
contact_person = fcp,
|
||||
contact_phone = fphone,
|
||||
active = f_active,
|
||||
project_id = project.id,
|
||||
)
|
||||
db.session.add(facility)
|
||||
db.session.flush()
|
||||
created_facilities += 1
|
||||
log_action(ACTION_CREATE, 'Facility', facility.id, facility.name,
|
||||
f'project_id={project.id}; source=bulk_import')
|
||||
logger.info('BULK IMPORT | facility_created | name=%s project_id=%s by=%s',
|
||||
facility.name, project.id, current_user.username)
|
||||
|
||||
db.session.commit()
|
||||
logger.info(
|
||||
'BULK IMPORT COMMITTED | by=%s | contracts_new=%s contracts_reused=%s '
|
||||
'facilities_new=%s facilities_skipped=%s',
|
||||
current_user.username, created_contracts, reused_contracts,
|
||||
created_facilities, skipped_facilities,
|
||||
)
|
||||
|
||||
parts = []
|
||||
if created_contracts:
|
||||
parts.append(f'{created_contracts} contract(s) created')
|
||||
if reused_contracts:
|
||||
parts.append(f'{reused_contracts} existing contract(s) reused')
|
||||
if created_facilities:
|
||||
parts.append(f'{created_facilities} facilit{"y" if created_facilities == 1 else "ies"} created')
|
||||
if skipped_facilities:
|
||||
parts.append(f'{skipped_facilities} duplicate facilit{"y" if skipped_facilities == 1 else "ies"} skipped')
|
||||
flash('Import complete: ' + ', '.join(parts) + '.', 'success')
|
||||
return redirect(url_for('projects.index'))
|
||||
|
||||
# ── Phase 1: parse and validate ───────────────────────────────────────
|
||||
preview_rows = []
|
||||
errors = []
|
||||
raw_valid_rows = []
|
||||
|
||||
if request.method == 'POST':
|
||||
file = request.files.get('xlsx_file')
|
||||
|
||||
if not file or not file.filename:
|
||||
flash('Please select an Excel file to upload.', 'warning')
|
||||
return render_template('projects/import.html')
|
||||
|
||||
if not file.filename.lower().endswith(('.xlsx', '.xlsm')):
|
||||
flash('Only .xlsx / .xlsm files are accepted.', 'danger')
|
||||
return render_template('projects/import.html')
|
||||
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
wb = load_workbook(filename=io.BytesIO(file.stream.read()), data_only=True)
|
||||
except Exception as exc:
|
||||
flash(f'Could not open workbook: {exc}', 'danger')
|
||||
return render_template('projects/import.html')
|
||||
|
||||
# ── Parse Contracts sheet ─────────────────────────────────────────
|
||||
if 'Contracts' not in wb.sheetnames:
|
||||
flash('Workbook is missing the "Contracts" sheet. Download the template and try again.', 'danger')
|
||||
return render_template('projects/import.html')
|
||||
|
||||
ws_c = wb['Contracts']
|
||||
c_rows = list(ws_c.iter_rows(values_only=True))
|
||||
if not c_rows:
|
||||
flash('"Contracts" sheet is empty.', 'danger')
|
||||
return render_template('projects/import.html')
|
||||
|
||||
c_headers = [str(h).strip().lower() if h else '' for h in c_rows[0]]
|
||||
if 'contract_name' not in c_headers:
|
||||
flash('"Contracts" sheet is missing required column "contract_name".', 'danger')
|
||||
return render_template('projects/import.html')
|
||||
|
||||
def col(headers, name):
|
||||
try:
|
||||
return headers.index(name)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
c_name_idx = col(c_headers, 'contract_name')
|
||||
c_desc_idx = col(c_headers, 'description')
|
||||
c_active_idx = col(c_headers, 'active')
|
||||
|
||||
# name → {description, active} for valid contracts found in sheet
|
||||
contract_sheet = {} # lower-name → dict
|
||||
contract_errors = []
|
||||
|
||||
for i, row in enumerate(c_rows[1:], start=2):
|
||||
cname = str(row[c_name_idx]).strip() if row[c_name_idx] is not None else ''
|
||||
if not cname or cname.lower() == 'none':
|
||||
contract_errors.append(f'Row {i}: contract_name is required')
|
||||
continue
|
||||
raw_active = row[c_active_idx] if c_active_idx is not None else None
|
||||
c_active = _parse_bool(raw_active, default=True)
|
||||
cdesc = str(row[c_desc_idx]).strip() if (c_desc_idx is not None and row[c_desc_idx] is not None) else ''
|
||||
contract_sheet[cname.lower()] = {
|
||||
'contract_name': cname,
|
||||
'description': cdesc or None,
|
||||
'contract_active': c_active,
|
||||
}
|
||||
|
||||
# ── Parse Facilities sheet ────────────────────────────────────────
|
||||
facility_sheet_rows = []
|
||||
if 'Facilities' in wb.sheetnames:
|
||||
ws_f = wb['Facilities']
|
||||
f_rows = list(ws_f.iter_rows(values_only=True))
|
||||
if f_rows:
|
||||
f_headers = [str(h).strip().lower() if h else '' for h in f_rows[0]]
|
||||
f_cname_idx = col(f_headers, 'contract_name')
|
||||
f_fname_idx = col(f_headers, 'facility_name')
|
||||
f_addr_idx = col(f_headers, 'address')
|
||||
f_cp_idx = col(f_headers, 'contact_person')
|
||||
f_phone_idx = col(f_headers, 'contact_phone')
|
||||
f_active_idx = col(f_headers, 'active')
|
||||
|
||||
if f_cname_idx is None or f_fname_idx is None:
|
||||
flash('"Facilities" sheet is missing required columns "contract_name" or "facility_name".', 'danger')
|
||||
return render_template('projects/import.html')
|
||||
|
||||
for i, row in enumerate(f_rows[1:], start=2):
|
||||
cname = str(row[f_cname_idx]).strip() if row[f_cname_idx] is not None else ''
|
||||
fname = str(row[f_fname_idx]).strip() if row[f_fname_idx] is not None else ''
|
||||
if not cname or cname.lower() == 'none':
|
||||
continue # skip blank rows silently
|
||||
if not fname or fname.lower() == 'none':
|
||||
continue
|
||||
raw_active = row[f_active_idx] if f_active_idx is not None else None
|
||||
facility_sheet_rows.append({
|
||||
'sheet_row': i,
|
||||
'contract_name': cname,
|
||||
'facility_name': fname,
|
||||
'address': str(row[f_addr_idx]).strip() if (f_addr_idx is not None and row[f_addr_idx]) else '',
|
||||
'contact_person': str(row[f_cp_idx]).strip() if (f_cp_idx is not None and row[f_cp_idx]) else '',
|
||||
'contact_phone': str(row[f_phone_idx]).strip() if (f_phone_idx is not None and row[f_phone_idx]) else '',
|
||||
'facility_active': _parse_bool(raw_active, default=True),
|
||||
})
|
||||
|
||||
# ── Build preview rows ────────────────────────────────────────────
|
||||
#
|
||||
# Strategy: one preview row per (contract) from Contracts sheet,
|
||||
# then one preview row per facility from Facilities sheet.
|
||||
# Validation: facility's contract_name must appear in Contracts sheet.
|
||||
|
||||
existing_projects = {
|
||||
p.name.strip().lower(): p
|
||||
for p in Project.query.all()
|
||||
}
|
||||
|
||||
row_num = 0
|
||||
|
||||
# Contract rows
|
||||
for lower_name, cdata in contract_sheet.items():
|
||||
row_num += 1
|
||||
row_errors = []
|
||||
if lower_name in existing_projects:
|
||||
status = 'exists'
|
||||
note = 'Contract already exists — will be reused'
|
||||
else:
|
||||
status = 'ok'
|
||||
note = ''
|
||||
preview_rows.append({
|
||||
'row': row_num,
|
||||
'sheet': 'Contracts',
|
||||
'contract_name': cdata['contract_name'],
|
||||
'facility_name': '',
|
||||
'note': note,
|
||||
'status': status,
|
||||
'errors': row_errors,
|
||||
})
|
||||
if status != 'error':
|
||||
raw_valid_rows.append({
|
||||
'contract_name': cdata['contract_name'],
|
||||
'description': cdata['description'],
|
||||
'contract_active': cdata['contract_active'],
|
||||
'facility_name': None,
|
||||
})
|
||||
|
||||
# Facility rows
|
||||
seen_facilities = set() # (lower_contract, lower_facility) within file
|
||||
for frow in facility_sheet_rows:
|
||||
row_num += 1
|
||||
row_errors = []
|
||||
lower_c = frow['contract_name'].lower()
|
||||
lower_f = frow['facility_name'].lower()
|
||||
|
||||
if lower_c not in contract_sheet:
|
||||
row_errors.append(
|
||||
f'Contract "{frow["contract_name"]}" not found in the Contracts sheet'
|
||||
)
|
||||
|
||||
dup_key = (lower_c, lower_f)
|
||||
if dup_key in seen_facilities:
|
||||
row_errors.append('Duplicate facility name within this contract in the file')
|
||||
else:
|
||||
seen_facilities.add(dup_key)
|
||||
|
||||
# Check DB for existing facility with same name in same contract
|
||||
db_conflict = False
|
||||
if not row_errors:
|
||||
proj = existing_projects.get(lower_c)
|
||||
if proj:
|
||||
db_conflict = Facility.query.filter(
|
||||
Facility.project_id == proj.id,
|
||||
db.func.lower(Facility.name) == lower_f,
|
||||
).first() is not None
|
||||
|
||||
if db_conflict:
|
||||
status = 'exists'
|
||||
note = 'Facility already exists in this contract — will be skipped'
|
||||
elif row_errors:
|
||||
status = 'error'
|
||||
note = ''
|
||||
else:
|
||||
status = 'ok'
|
||||
note = ''
|
||||
|
||||
preview_rows.append({
|
||||
'row': frow['sheet_row'],
|
||||
'sheet': 'Facilities',
|
||||
'contract_name': frow['contract_name'],
|
||||
'facility_name': frow['facility_name'],
|
||||
'note': note,
|
||||
'status': status,
|
||||
'errors': row_errors,
|
||||
})
|
||||
if status == 'ok':
|
||||
raw_valid_rows.append({
|
||||
'contract_name': frow['contract_name'],
|
||||
'description': None,
|
||||
'contract_active': True,
|
||||
'facility_name': frow['facility_name'],
|
||||
'address': frow['address'] or None,
|
||||
'contact_person': frow['contact_person'] or None,
|
||||
'contact_phone': frow['contact_phone'] or None,
|
||||
'facility_active': frow['facility_active'],
|
||||
})
|
||||
|
||||
valid_count = sum(1 for r in preview_rows if r['status'] == 'ok')
|
||||
has_errors = any(r['status'] == 'error' for r in preview_rows)
|
||||
rows_json = json.dumps(raw_valid_rows)
|
||||
|
||||
# Contract-sheet parse errors shown as flash
|
||||
for ce in contract_errors:
|
||||
flash(ce, 'warning')
|
||||
|
||||
return render_template(
|
||||
'projects/import.html',
|
||||
preview_rows = preview_rows,
|
||||
valid_count = valid_count,
|
||||
has_errors = has_errors,
|
||||
rows_json = rows_json,
|
||||
)
|
||||
|
||||
return render_template('projects/import.html')
|
||||
|
||||
|
||||
def _parse_bool(value, default=True):
|
||||
"""Convert Excel cell value to Python bool for 'active' columns."""
|
||||
if value is None:
|
||||
return default
|
||||
s = str(value).strip().lower()
|
||||
if s in ('yes', 'true', '1', 'y'):
|
||||
return True
|
||||
if s in ('no', 'false', '0', 'n'):
|
||||
return False
|
||||
return default
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,524 @@
|
||||
"""
|
||||
app/routes/scheduled_reports.py
|
||||
--------------------------------
|
||||
CRUD management for ScheduledReport configs + cron-triggered send endpoint.
|
||||
|
||||
Admin/supervisor access for management.
|
||||
The /send route is token-protected (same DIGEST_SECRET) for cron use.
|
||||
|
||||
Cron examples
|
||||
-------------
|
||||
# Daily at 07:00
|
||||
0 7 * * * curl -s -X POST https://yourdomain.com/scheduled-reports/send \
|
||||
-d "token=YOUR_DIGEST_SECRET&frequency=daily"
|
||||
|
||||
# Weekly on Monday 07:00
|
||||
0 7 * * 1 curl -s -X POST https://yourdomain.com/scheduled-reports/send \
|
||||
-d "token=YOUR_DIGEST_SECRET&frequency=weekly"
|
||||
|
||||
# Monthly on the 1st at 07:00
|
||||
0 7 1 * * curl -s -X POST https://yourdomain.com/scheduled-reports/send \
|
||||
-d "token=YOUR_DIGEST_SECRET&frequency=monthly"
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import (Blueprint, render_template, redirect, url_for, flash,
|
||||
request, jsonify, current_app, abort)
|
||||
from flask_login import login_required, current_user
|
||||
from flask_mail import Message
|
||||
from sqlalchemy import func
|
||||
|
||||
from app import db, mail, csrf
|
||||
from app.models.scheduled_report import ScheduledReport
|
||||
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, admin_required
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
|
||||
from app.utils.time_utils import now_eastern
|
||||
from app.utils.sla import sla_status
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('scheduled_reports', __name__, url_prefix='/scheduled-reports')
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _compute_next_send(frequency: str, from_dt: datetime = None) -> datetime:
|
||||
"""Return the next send datetime for a given frequency.
|
||||
|
||||
Monthly cadence always targets the 1st of the next month at 07:00.
|
||||
The December branch is required because datetime.replace(month=13)
|
||||
raises ValueError — incrementing the month directly is not safe in
|
||||
general, but targeting day=1 avoids the separate last-day-of-month
|
||||
(28/29/30/31) edge case that would affect mid-month scheduling.
|
||||
"""
|
||||
now = from_dt or now_eastern()
|
||||
if frequency == 'daily':
|
||||
return (now + timedelta(days=1)).replace(hour=7, minute=0, second=0, microsecond=0)
|
||||
if frequency == 'weekly':
|
||||
return (now + timedelta(weeks=1)).replace(hour=7, minute=0, second=0, microsecond=0)
|
||||
# monthly: first of next month — December rolls over to Jan of next year.
|
||||
if now.month == 12:
|
||||
return now.replace(year=now.year + 1, month=1, day=1, hour=7, minute=0, second=0, microsecond=0)
|
||||
return now.replace(month=now.month + 1, day=1, hour=7, minute=0, second=0, microsecond=0)
|
||||
|
||||
|
||||
def _date_window(frequency: str):
|
||||
"""Return (start, end) covering the period just elapsed for this frequency."""
|
||||
end = now_eastern()
|
||||
if frequency == 'daily':
|
||||
start = end - timedelta(days=1)
|
||||
elif frequency == 'weekly':
|
||||
start = end - timedelta(weeks=1)
|
||||
else:
|
||||
start = end - timedelta(days=30)
|
||||
return start, end
|
||||
|
||||
|
||||
def _build_report_data(report: ScheduledReport, start: datetime, end: datetime) -> dict:
|
||||
"""Assemble the data dict passed to the email template."""
|
||||
data = {
|
||||
'report': report,
|
||||
'start': start,
|
||||
'end': end,
|
||||
'facility': report.facility,
|
||||
}
|
||||
|
||||
fid_filter = [report.facility_id] if report.facility_id else None
|
||||
|
||||
def _si(q):
|
||||
if fid_filter:
|
||||
return q.filter(Inspection.facility_id.in_(fid_filter))
|
||||
return q
|
||||
|
||||
def _iq(q):
|
||||
if fid_filter:
|
||||
return q.outerjoin(Area, Issue.area_id == Area.id).filter(
|
||||
db.or_(
|
||||
Issue.facility_id.in_(fid_filter),
|
||||
Area.facility_id.in_(fid_filter)
|
||||
)
|
||||
)
|
||||
return q
|
||||
|
||||
if report.report_type in ('summary', 'facility'):
|
||||
base = _si(Inspection.query.filter(
|
||||
Inspection.inspection_date >= start,
|
||||
Inspection.inspection_date <= end,
|
||||
))
|
||||
data['total_inspections'] = base.count()
|
||||
data['completed'] = base.filter(Inspection.status == 'completed').count()
|
||||
|
||||
avg = 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_val = _si(avg).scalar()
|
||||
data['avg_score'] = round(float(avg_val), 2) if avg_val else None
|
||||
|
||||
data['open_issues'] = _iq(Issue.query.filter(
|
||||
Issue.status.in_(['open', 'in_progress'])
|
||||
)).count()
|
||||
|
||||
data['critical_issues'] = _iq(Issue.query.filter(
|
||||
Issue.severity.in_(['critical', 'high']),
|
||||
Issue.status != 'resolved',
|
||||
)).order_by(Issue.reported_at.desc()).limit(10).all()
|
||||
|
||||
data['facility_scores'] = db.session.query(
|
||||
Facility.name,
|
||||
func.avg(Inspection.overall_score).label('avg'),
|
||||
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 fid_filter:
|
||||
data['facility_scores'] = data['facility_scores'].filter(Facility.id.in_(fid_filter))
|
||||
data['facility_scores'] = data['facility_scores'].group_by(Facility.id, Facility.name)\
|
||||
.order_by(func.avg(Inspection.overall_score).desc()).all()
|
||||
|
||||
if report.report_type == 'issues':
|
||||
all_issues = _iq(Issue.query.filter(
|
||||
Issue.status != 'resolved',
|
||||
)).order_by(Issue.severity.desc(), Issue.reported_at.asc()).all()
|
||||
|
||||
data['issues'] = all_issues
|
||||
|
||||
# Group by facility with per-issue SLA status for the enhanced email template
|
||||
fac_map = {}
|
||||
sla_breached = sla_at_risk = 0
|
||||
for issue in all_issues:
|
||||
fac = issue.resolved_facility
|
||||
fname = fac.name if fac else '(No Facility)'
|
||||
s = sla_status(issue)
|
||||
if s == 'breached':
|
||||
sla_breached += 1
|
||||
elif s == 'at_risk':
|
||||
sla_at_risk += 1
|
||||
fac_map.setdefault(fname, []).append((issue, s))
|
||||
|
||||
data['issues_by_facility'] = sorted(fac_map.items())
|
||||
data['sla_breached'] = sla_breached
|
||||
data['sla_at_risk'] = sla_at_risk
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _build_csv(report: ScheduledReport, start: datetime, end: datetime) -> bytes:
|
||||
"""Return CSV bytes appropriate for the report type."""
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
|
||||
fid_filter = [report.facility_id] if report.facility_id else None
|
||||
|
||||
if report.report_type == 'issues':
|
||||
writer.writerow(['ID', 'Reported At', 'Facility', 'Area', 'Severity',
|
||||
'Description', 'Status', 'Assigned To'])
|
||||
q = Issue.query
|
||||
if fid_filter:
|
||||
q = q.outerjoin(Area, Issue.area_id == Area.id).filter(
|
||||
db.or_(
|
||||
Issue.facility_id.in_(fid_filter),
|
||||
Area.facility_id.in_(fid_filter)
|
||||
)
|
||||
)
|
||||
for i in q.filter(Issue.status != 'resolved').order_by(Issue.reported_at.desc()).all():
|
||||
writer.writerow([
|
||||
i.id,
|
||||
i.reported_at.strftime('%Y-%m-%d %H:%M'),
|
||||
i.resolved_facility.name if i.resolved_facility else '—',
|
||||
i.area.name if i.area else '—',
|
||||
i.severity,
|
||||
i.description.replace('\n', ' '),
|
||||
i.status,
|
||||
i.assigned_user.username if i.assigned_user else '',
|
||||
])
|
||||
else:
|
||||
writer.writerow(['ID', 'Date', 'Facility', 'Area', 'Inspector',
|
||||
'Template', 'Score', 'Status'])
|
||||
q = Inspection.query.filter(
|
||||
Inspection.inspection_date >= start,
|
||||
Inspection.inspection_date <= end,
|
||||
)
|
||||
if fid_filter:
|
||||
q = q.filter(Inspection.facility_id.in_(fid_filter))
|
||||
for i in q.order_by(Inspection.inspection_date.desc()).all():
|
||||
writer.writerow([
|
||||
i.id,
|
||||
i.inspection_date.strftime('%Y-%m-%d %H:%M'),
|
||||
i.facility.name,
|
||||
i.area.name if i.area else '',
|
||||
i.inspector.username,
|
||||
i.template.name,
|
||||
i.overall_score or '',
|
||||
i.status,
|
||||
])
|
||||
|
||||
return buf.getvalue().encode('utf-8')
|
||||
|
||||
|
||||
def _send_report(report: ScheduledReport):
|
||||
"""Build and dispatch the email for a single ScheduledReport."""
|
||||
if not current_app.config.get('MAIL_SERVER'):
|
||||
logger.warning('SCHEDULED REPORT SKIPPED | id=%s | no MAIL_SERVER', report.id)
|
||||
return False
|
||||
|
||||
recipients = report.recipient_list()
|
||||
if not recipients:
|
||||
logger.warning('SCHEDULED REPORT SKIPPED | id=%s | no recipients', report.id)
|
||||
return False
|
||||
|
||||
frequency = report.frequency
|
||||
start, end = _date_window(frequency)
|
||||
data = _build_report_data(report, start, end)
|
||||
base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/')
|
||||
|
||||
html_body = render_template('scheduled_reports/email.html',
|
||||
base_url=base_url, **data)
|
||||
text_body = render_template('scheduled_reports/email.txt',
|
||||
base_url=base_url, **data)
|
||||
|
||||
subject = (f'[JQC] {report.frequency.title()} Report — {report.name} '
|
||||
f'({start.strftime("%b %d")}–{end.strftime("%b %d, %Y")})')
|
||||
|
||||
sender = current_app.config.get(
|
||||
'MAIL_DEFAULT_SENDER',
|
||||
current_app.config.get('MAIL_USERNAME', 'noreply@janitorialqc.local'),
|
||||
)
|
||||
|
||||
msg = Message(subject=subject, sender=sender, recipients=recipients,
|
||||
body=text_body, html=html_body)
|
||||
|
||||
if report.include_csv:
|
||||
csv_bytes = _build_csv(report, start, end)
|
||||
fname = f'jqc_report_{report.frequency}_{start.strftime("%Y%m%d")}.csv'
|
||||
msg.attach(fname, 'text/csv', csv_bytes)
|
||||
|
||||
if report.include_pdf:
|
||||
try:
|
||||
from app.utils.pdf_export import generate_scheduled_report_pdf
|
||||
pdf_bytes = generate_scheduled_report_pdf(
|
||||
report_name = report.name,
|
||||
frequency = report.frequency,
|
||||
start = start,
|
||||
end = end,
|
||||
facility_name = report.facility.name if report.facility else None,
|
||||
data = data,
|
||||
)
|
||||
pdf_fname = f'jqc_report_{report.frequency}_{start.strftime("%Y%m%d")}.pdf'
|
||||
msg.attach(pdf_fname, 'application/pdf', pdf_bytes)
|
||||
except Exception as exc:
|
||||
logger.error('SCHEDULED REPORT PDF FAILED | id=%s | error=%s', report.id, exc)
|
||||
|
||||
try:
|
||||
mail.send(msg)
|
||||
logger.info('SCHEDULED REPORT SENT | id=%s | name=%r | recipients=%s',
|
||||
report.id, report.name, recipients)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error('SCHEDULED REPORT FAILED | id=%s | error=%s', report.id, exc)
|
||||
return False
|
||||
|
||||
|
||||
# ── CRUD ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
@admin_required
|
||||
def index():
|
||||
reports = ScheduledReport.query.order_by(ScheduledReport.name).all()
|
||||
return render_template('scheduled_reports/index.html', reports=reports)
|
||||
|
||||
|
||||
@bp.route('/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def create():
|
||||
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
||||
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name', '').strip()
|
||||
report_type = request.form.get('report_type', 'summary')
|
||||
frequency = request.form.get('frequency', 'weekly')
|
||||
facility_id = request.form.get('facility_id', type=int) or None
|
||||
recipients = [e.strip() for e in request.form.get('recipients', '').split(',') if e.strip()]
|
||||
include_pdf = bool(request.form.get('include_pdf'))
|
||||
include_csv = bool(request.form.get('include_csv'))
|
||||
|
||||
if not name:
|
||||
flash('Report name is required.', 'warning')
|
||||
return render_template('scheduled_reports/form.html',
|
||||
facilities=facilities, title='New Scheduled Report')
|
||||
if not recipients:
|
||||
flash('At least one recipient email is required.', 'warning')
|
||||
return render_template('scheduled_reports/form.html',
|
||||
facilities=facilities, title='New Scheduled Report')
|
||||
|
||||
report = ScheduledReport(
|
||||
name = name,
|
||||
report_type = report_type,
|
||||
frequency = frequency,
|
||||
facility_id = facility_id,
|
||||
recipients = recipients,
|
||||
include_pdf = include_pdf,
|
||||
include_csv = include_csv,
|
||||
active = True,
|
||||
created_by = current_user.id,
|
||||
created_at = now_eastern(),
|
||||
next_send_at = _compute_next_send(frequency),
|
||||
)
|
||||
db.session.add(report)
|
||||
db.session.commit()
|
||||
log_action(ACTION_CREATE, 'ScheduledReport', report.id, report.name,
|
||||
f'frequency={frequency}; recipients={len(recipients)}')
|
||||
flash(f'Scheduled report "{report.name}" created.', 'success')
|
||||
return redirect(url_for('scheduled_reports.index'))
|
||||
|
||||
return render_template('scheduled_reports/form.html',
|
||||
facilities=facilities, title='New Scheduled Report')
|
||||
|
||||
|
||||
@bp.route('/<int:report_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def edit(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()
|
||||
|
||||
if request.method == 'POST':
|
||||
report.name = request.form.get('name', '').strip() or report.name
|
||||
report.report_type = request.form.get('report_type', report.report_type)
|
||||
report.frequency = request.form.get('frequency', report.frequency)
|
||||
report.facility_id = request.form.get('facility_id', type=int) or None
|
||||
report.recipients = [e.strip() for e in request.form.get('recipients', '').split(',') if e.strip()]
|
||||
report.include_pdf = bool(request.form.get('include_pdf'))
|
||||
report.include_csv = bool(request.form.get('include_csv'))
|
||||
report.active = bool(request.form.get('active'))
|
||||
report.next_send_at = _compute_next_send(report.frequency)
|
||||
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'ScheduledReport', report.id, report.name,
|
||||
f'frequency={report.frequency}; active={report.active}')
|
||||
flash(f'Scheduled report "{report.name}" updated.', 'success')
|
||||
return redirect(url_for('scheduled_reports.index'))
|
||||
|
||||
return render_template('scheduled_reports/form.html', report=report,
|
||||
facilities=facilities, title='Edit Scheduled Report')
|
||||
|
||||
|
||||
@bp.route('/<int:report_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete(report_id):
|
||||
report = db.session.get(ScheduledReport, report_id)
|
||||
if report is None:
|
||||
abort(404)
|
||||
name = report.name
|
||||
rid = report.id
|
||||
db.session.delete(report)
|
||||
db.session.commit()
|
||||
log_action(ACTION_DELETE, 'ScheduledReport', rid, name)
|
||||
flash(f'Scheduled report "{name}" deleted.', 'success')
|
||||
return redirect(url_for('scheduled_reports.index'))
|
||||
|
||||
|
||||
@bp.route('/<int:report_id>/preview')
|
||||
@login_required
|
||||
@admin_required
|
||||
def preview(report_id):
|
||||
"""Render the scheduled report email in-browser for review."""
|
||||
report = db.session.get(ScheduledReport, report_id)
|
||||
if report is None:
|
||||
abort(404)
|
||||
start, end = _date_window(report.frequency)
|
||||
data = _build_report_data(report, start, end)
|
||||
base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/')
|
||||
|
||||
current_app.logger.info(
|
||||
'SCHEDULED REPORT PREVIEW | id=%s | name=%r | by=%s',
|
||||
report.id, report.name, current_user.username,
|
||||
)
|
||||
|
||||
return render_template('scheduled_reports/email.html',
|
||||
base_url=base_url, **data)
|
||||
|
||||
|
||||
@bp.route('/<int:report_id>/preview-pdf')
|
||||
@login_required
|
||||
@admin_required
|
||||
def preview_pdf(report_id):
|
||||
"""Generate and stream the PDF attachment for in-browser review."""
|
||||
from flask import Response
|
||||
from app.utils.pdf_export import generate_scheduled_report_pdf
|
||||
|
||||
report = db.session.get(ScheduledReport, report_id)
|
||||
if report is None:
|
||||
abort(404)
|
||||
start, end = _date_window(report.frequency)
|
||||
data = _build_report_data(report, start, end)
|
||||
|
||||
pdf_bytes = generate_scheduled_report_pdf(
|
||||
report_name = report.name,
|
||||
frequency = report.frequency,
|
||||
start = start,
|
||||
end = end,
|
||||
facility_name = report.facility.name if report.facility else None,
|
||||
data = data,
|
||||
)
|
||||
|
||||
filename = f'jqc_report_{report.frequency}_{start.strftime("%Y%m%d")}.pdf'
|
||||
|
||||
current_app.logger.info(
|
||||
'SCHEDULED REPORT PDF PREVIEW | id=%s | name=%r | by=%s',
|
||||
report.id, report.name, current_user.username,
|
||||
)
|
||||
|
||||
return Response(
|
||||
pdf_bytes,
|
||||
mimetype='application/pdf',
|
||||
headers={'Content-Disposition': f'inline; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/<int:report_id>/send-now', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def send_now(report_id):
|
||||
"""Manually trigger a single report — useful for testing."""
|
||||
report = db.session.get(ScheduledReport, report_id)
|
||||
if report is None:
|
||||
abort(404)
|
||||
ok = _send_report(report)
|
||||
if ok:
|
||||
report.last_sent_at = now_eastern()
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'ScheduledReport', report.id, report.name,
|
||||
f'manual send_now by {current_user.username}; '
|
||||
f'frequency={report.frequency}; recipients={len(report.recipient_list())}')
|
||||
flash(f'Report "{report.name}" sent successfully.', 'success')
|
||||
else:
|
||||
flash(f'Failed to send report "{report.name}". Check application logs.', 'danger')
|
||||
return redirect(url_for('scheduled_reports.index'))
|
||||
|
||||
|
||||
# ── Cron endpoint ─────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/send', methods=['POST'])
|
||||
@csrf.exempt
|
||||
def send():
|
||||
"""Token-protected endpoint called by cron to dispatch due reports.
|
||||
|
||||
POST body: token=<DIGEST_SECRET>&frequency=daily|weekly|monthly
|
||||
"""
|
||||
token = request.form.get('token') or request.args.get('token')
|
||||
frequency = request.form.get('frequency', 'daily')
|
||||
expected = current_app.config.get('DIGEST_SECRET')
|
||||
|
||||
if not expected or token != expected:
|
||||
logger.warning('SCHEDULED REPORTS SEND REJECTED | bad/missing token')
|
||||
return jsonify({'ok': False, 'error': 'unauthorized'}), 403
|
||||
|
||||
if frequency not in ('daily', 'weekly', 'monthly'):
|
||||
return jsonify({'ok': False, 'error': 'invalid frequency'}), 400
|
||||
|
||||
now = now_eastern()
|
||||
reports = ScheduledReport.query.filter_by(active=True, frequency=frequency).all()
|
||||
# Only send reports whose next_send_at is due (or not yet set)
|
||||
due = [r for r in reports if r.next_send_at is None or r.next_send_at <= now]
|
||||
|
||||
sent, failed = 0, 0
|
||||
for report in due:
|
||||
ok = _send_report(report)
|
||||
# Always advance next_send_at so a failed report does not get
|
||||
# retried on every subsequent cron run. last_sent_at is only
|
||||
# updated on a successful delivery so the UI accurately reflects
|
||||
# when the last good email was dispatched.
|
||||
report.next_send_at = _compute_next_send(frequency, now)
|
||||
if ok:
|
||||
report.last_sent_at = now
|
||||
sent += 1
|
||||
else:
|
||||
failed += 1
|
||||
logger.error(
|
||||
'SCHEDULED REPORT FAILED | id=%s | name=%r | frequency=%s',
|
||||
report.id, report.name, frequency,
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
logger.info('SCHEDULED REPORTS CRON | frequency=%s | due=%s | sent=%s | failed=%s',
|
||||
frequency, len(due), sent, failed)
|
||||
return jsonify({'ok': True, 'frequency': frequency, 'sent': sent, 'failed': failed})
|
||||
@@ -0,0 +1,371 @@
|
||||
import os
|
||||
import logging
|
||||
|
||||
from flask import (Blueprint, render_template, redirect, url_for,
|
||||
flash, request, current_app, jsonify, abort)
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from app import db
|
||||
from app.models.support import SupportTicket, SupportTicketReply
|
||||
from app.models.user import User
|
||||
from app.models.facility import Facility
|
||||
from app.utils.decorators import supervisor_required
|
||||
from app.utils.scope import get_customer_scope
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE
|
||||
from app.utils.time_utils import now_eastern
|
||||
from app.utils.notifications import notify
|
||||
|
||||
bp = Blueprint('support', __name__, url_prefix='/support')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Groq system prompt ────────────────────────────────────────────────────────
|
||||
|
||||
_SYSTEM_PROMPT = """\
|
||||
You are JQC Support, a friendly assistant for customers of JQC (Janitorial Quality Control), \
|
||||
a commercial cleaning quality management platform.
|
||||
|
||||
Help customers with:
|
||||
- Navigating the portal: Dashboard, Inspections, Issues, Reports pages
|
||||
- Inspection scores: 90%+ = Excellent, 70-89% = Satisfactory, below 70% = Needs Improvement
|
||||
- SLA timelines: Critical issues = 4 h, High = 24 h, Medium = 72 h, Low = 168 h
|
||||
- Issue statuses: Open → In Progress → Pending Verification → Resolved
|
||||
- Following issues to receive email/in-app update notifications
|
||||
- Reporting new cleaning concerns via the Issues > Log Issue page
|
||||
- Understanding facility scorecards and trend charts in Reports
|
||||
|
||||
Rules:
|
||||
- Keep answers concise (3-5 sentences max) and friendly.
|
||||
- Never invent specific staff names, contract prices, schedules, or contact numbers.
|
||||
- If the customer has an access problem, billing question, or a concern you genuinely \
|
||||
cannot resolve through guidance, say so clearly and suggest they click \
|
||||
"Submit to Support" to reach the admin team directly.\
|
||||
"""
|
||||
|
||||
# Preset FAQ questions shown as quick-reply chips on first load
|
||||
FAQS = [
|
||||
{'icon': 'bi-clipboard-check', 'text': 'How do I view my inspection reports?'},
|
||||
{'icon': 'bi-graph-up', 'text': 'What do inspection scores mean?'},
|
||||
{'icon': 'bi-exclamation-circle','text': 'How do I track an open issue?'},
|
||||
{'icon': 'bi-megaphone', 'text': 'How do I report a cleaning concern?'},
|
||||
{'icon': 'bi-alarm', 'text': 'What is SLA and how does it work?'},
|
||||
{'icon': 'bi-bell', 'text': 'How do I get notified on issue updates?'},
|
||||
]
|
||||
|
||||
|
||||
# ── Customer chat page ────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/chat')
|
||||
@login_required
|
||||
def chat():
|
||||
if current_user.role != 'customer':
|
||||
return redirect(url_for('support.admin_tickets'))
|
||||
|
||||
cids = get_customer_scope(current_user) or []
|
||||
facilities = (Facility.query
|
||||
.filter(Facility.id.in_(cids), Facility.active == True)
|
||||
.order_by(Facility.name).all()) if cids else []
|
||||
|
||||
groq_ready = bool(os.environ.get('GROQ_API_KEY'))
|
||||
return render_template('support/chat.html',
|
||||
faqs=FAQS,
|
||||
facilities=facilities,
|
||||
groq_ready=groq_ready)
|
||||
|
||||
|
||||
# ── Groq chat AJAX endpoint ───────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/chat/message', methods=['POST'])
|
||||
@login_required
|
||||
def chat_message():
|
||||
if current_user.role != 'customer':
|
||||
return jsonify({'error': 'Forbidden'}), 403
|
||||
|
||||
api_key = os.environ.get('GROQ_API_KEY')
|
||||
if not api_key:
|
||||
return jsonify({'reply': (
|
||||
"I'm sorry, the AI assistant isn't configured right now. "
|
||||
"Please use the **Submit to Support** form to reach our team directly."
|
||||
)})
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
history = data.get('messages', []) # list of {role, content} dicts
|
||||
user_message = data.get('message', '').strip()
|
||||
|
||||
if not user_message:
|
||||
return jsonify({'error': 'Empty message'}), 400
|
||||
|
||||
try:
|
||||
from groq import Groq
|
||||
client = Groq(api_key=api_key)
|
||||
|
||||
messages = [{'role': 'system', 'content': _SYSTEM_PROMPT}]
|
||||
# Append prior conversation (cap at last 20 turns to control token usage)
|
||||
for m in history[-20:]:
|
||||
if m.get('role') in ('user', 'assistant') and m.get('content'):
|
||||
messages.append({'role': m['role'], 'content': m['content']})
|
||||
messages.append({'role': 'user', 'content': user_message})
|
||||
|
||||
model = os.environ.get('GROQ_MODEL', 'llama-3.3-70b-versatile')
|
||||
completion = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
max_tokens=512,
|
||||
temperature=0.5,
|
||||
)
|
||||
reply = completion.choices[0].message.content.strip()
|
||||
return jsonify({'reply': reply})
|
||||
|
||||
except Exception as exc:
|
||||
logger.error('SUPPORT | Groq error: %s', exc)
|
||||
return jsonify({'reply': (
|
||||
"I ran into a problem reaching the AI assistant. "
|
||||
"Please try again, or use **Submit to Support** to contact our team."
|
||||
)})
|
||||
|
||||
|
||||
# ── Submit support ticket ─────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/tickets', methods=['POST'])
|
||||
@login_required
|
||||
def submit_ticket():
|
||||
if current_user.role != 'customer':
|
||||
abort(403)
|
||||
|
||||
subject = request.form.get('subject', '').strip()
|
||||
body = request.form.get('body', '').strip()
|
||||
facility_id = request.form.get('facility_id', type=int)
|
||||
|
||||
if not subject or not body:
|
||||
flash('Please fill in both subject and description.', 'warning')
|
||||
return redirect(url_for('support.chat'))
|
||||
|
||||
# Validate facility belongs to this customer
|
||||
cids = get_customer_scope(current_user) or []
|
||||
if facility_id and facility_id not in cids:
|
||||
facility_id = None
|
||||
|
||||
ticket = SupportTicket(
|
||||
customer_id = current_user.id,
|
||||
facility_id = facility_id,
|
||||
subject = subject,
|
||||
body = body,
|
||||
status = 'open',
|
||||
created_at = now_eastern(),
|
||||
)
|
||||
db.session.add(ticket)
|
||||
db.session.commit()
|
||||
|
||||
log_action(ACTION_CREATE, 'SupportTicket', ticket.id,
|
||||
f'#{ticket.id}: {subject[:60]}',
|
||||
f'customer={current_user.username}')
|
||||
|
||||
_notify_admins_new_ticket(ticket)
|
||||
|
||||
flash('Your message has been submitted. Our team will get back to you soon.', 'success')
|
||||
return redirect(url_for('support.my_tickets'))
|
||||
|
||||
|
||||
# ── Customer: my tickets list ─────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/my-tickets')
|
||||
@login_required
|
||||
def my_tickets():
|
||||
if current_user.role != 'customer':
|
||||
abort(403)
|
||||
|
||||
tickets = (SupportTicket.query
|
||||
.filter_by(customer_id=current_user.id)
|
||||
.order_by(SupportTicket.created_at.desc())
|
||||
.all())
|
||||
return render_template('support/my_tickets.html', tickets=tickets)
|
||||
|
||||
|
||||
# ── Customer: ticket detail ───────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/my-tickets/<int:ticket_id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def my_ticket_detail(ticket_id):
|
||||
if current_user.role != 'customer':
|
||||
abort(403)
|
||||
|
||||
ticket = db.session.get(SupportTicket, ticket_id)
|
||||
if ticket is None or ticket.customer_id != current_user.id:
|
||||
abort(404)
|
||||
|
||||
if request.method == 'POST':
|
||||
if ticket.status == 'closed':
|
||||
flash('This ticket is closed and cannot receive new replies.', 'warning')
|
||||
return redirect(url_for('support.my_ticket_detail', ticket_id=ticket_id))
|
||||
|
||||
body = request.form.get('body', '').strip()
|
||||
if not body:
|
||||
flash('Reply cannot be empty.', 'warning')
|
||||
return redirect(url_for('support.my_ticket_detail', ticket_id=ticket_id))
|
||||
|
||||
reply = SupportTicketReply(
|
||||
ticket_id = ticket.id,
|
||||
user_id = current_user.id,
|
||||
body = body,
|
||||
created_at = now_eastern(),
|
||||
)
|
||||
db.session.add(reply)
|
||||
# Reopen if it was answered so admin sees there's a follow-up
|
||||
if ticket.status == 'answered':
|
||||
ticket.status = 'open'
|
||||
db.session.commit()
|
||||
|
||||
log_action(ACTION_CREATE, 'SupportTicketReply', reply.id,
|
||||
f'ticket #{ticket.id}',
|
||||
f'customer reply by {current_user.username}')
|
||||
|
||||
_notify_admins_customer_reply(ticket, reply)
|
||||
flash('Your reply has been sent.', 'success')
|
||||
return redirect(url_for('support.my_ticket_detail', ticket_id=ticket_id))
|
||||
|
||||
replies = ticket.replies.order_by(SupportTicketReply.created_at.asc()).all()
|
||||
return render_template('support/my_ticket_detail.html',
|
||||
ticket=ticket, replies=replies)
|
||||
|
||||
|
||||
def _notify_admins_new_ticket(ticket):
|
||||
"""Create in-app notifications and send emails to all active admin users."""
|
||||
admins = User.query.filter_by(role='admin', active=True).all()
|
||||
if not admins:
|
||||
return
|
||||
|
||||
customer_label = ticket.customer.display_name if ticket.customer else 'Unknown'
|
||||
facility_label = ticket.facility.name if ticket.facility else 'N/A'
|
||||
link = url_for('support.admin_ticket_detail', ticket_id=ticket.id)
|
||||
title = f'New support ticket #{ticket.id} from {customer_label}'
|
||||
body = (f'Subject: {ticket.subject}\n'
|
||||
f'Facility: {facility_label}\n\n'
|
||||
f'{ticket.body[:300]}{"…" if len(ticket.body) > 300 else ""}')
|
||||
|
||||
for admin in admins:
|
||||
notify(
|
||||
recipient = admin,
|
||||
title = title,
|
||||
body = body,
|
||||
link = link,
|
||||
send_email = True,
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
# ── Admin: ticket list ────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/admin/tickets')
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def admin_tickets():
|
||||
status_filter = request.args.get('status', '')
|
||||
page = request.args.get('page', 1, type=int)
|
||||
|
||||
q = SupportTicket.query.order_by(SupportTicket.created_at.desc())
|
||||
if status_filter:
|
||||
q = q.filter(SupportTicket.status == status_filter)
|
||||
|
||||
tickets = q.paginate(page=page, per_page=25, error_out=False)
|
||||
return render_template('support/admin_tickets.html',
|
||||
tickets=tickets,
|
||||
status_filter=status_filter)
|
||||
|
||||
|
||||
# ── Admin: ticket detail + reply ──────────────────────────────────────────────
|
||||
|
||||
@bp.route('/admin/tickets/<int:ticket_id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def admin_ticket_detail(ticket_id):
|
||||
ticket = db.session.get(SupportTicket, ticket_id)
|
||||
if ticket is None:
|
||||
abort(404)
|
||||
|
||||
if request.method == 'POST':
|
||||
action = request.form.get('action')
|
||||
|
||||
if action == 'reply':
|
||||
body = request.form.get('body', '').strip()
|
||||
if not body:
|
||||
flash('Reply cannot be empty.', 'warning')
|
||||
return redirect(url_for('support.admin_ticket_detail', ticket_id=ticket_id))
|
||||
|
||||
reply = SupportTicketReply(
|
||||
ticket_id = ticket.id,
|
||||
user_id = current_user.id,
|
||||
body = body,
|
||||
created_at = now_eastern(),
|
||||
)
|
||||
db.session.add(reply)
|
||||
# Auto-advance status to answered if still open
|
||||
if ticket.status == 'open':
|
||||
ticket.status = 'answered'
|
||||
db.session.commit()
|
||||
|
||||
log_action(ACTION_UPDATE, 'SupportTicket', ticket.id,
|
||||
f'#{ticket.id}: {ticket.subject[:60]}',
|
||||
f'reply added by {current_user.username}')
|
||||
|
||||
_notify_customer_reply(ticket, reply)
|
||||
flash('Reply sent.', 'success')
|
||||
|
||||
elif action == 'status':
|
||||
new_status = request.form.get('status', '')
|
||||
if new_status in ('open', 'answered', 'closed'):
|
||||
ticket.status = new_status
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'SupportTicket', ticket.id,
|
||||
f'#{ticket.id}: {ticket.subject[:60]}',
|
||||
f'status={new_status}')
|
||||
flash(f'Ticket marked as {new_status}.', 'success')
|
||||
|
||||
return redirect(url_for('support.admin_ticket_detail', ticket_id=ticket_id))
|
||||
|
||||
replies = ticket.replies.order_by(SupportTicketReply.created_at.asc()).all()
|
||||
return render_template('support/admin_ticket_detail.html',
|
||||
ticket=ticket,
|
||||
replies=replies)
|
||||
|
||||
|
||||
def _notify_customer_reply(ticket, reply):
|
||||
"""Create an in-app notification and send an email to the customer."""
|
||||
if not ticket.customer:
|
||||
return
|
||||
|
||||
admin_name = reply.author.display_name if reply.author else 'Support Team'
|
||||
title = f'Reply to your support request #{ticket.id}'
|
||||
body = (f'{admin_name} replied to your ticket "{ticket.subject}":\n\n'
|
||||
f'{reply.body[:400]}{"…" if len(reply.body) > 400 else ""}')
|
||||
link = url_for('support.my_ticket_detail', ticket_id=ticket.id)
|
||||
|
||||
notify(
|
||||
recipient = ticket.customer,
|
||||
title = title,
|
||||
body = body,
|
||||
link = link,
|
||||
send_email = True,
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _notify_admins_customer_reply(ticket, reply):
|
||||
"""Notify admins when a customer adds a follow-up reply to their ticket."""
|
||||
admins = User.query.filter_by(role='admin', active=True).all()
|
||||
if not admins:
|
||||
return
|
||||
|
||||
customer_label = ticket.customer.display_name if ticket.customer else 'Unknown'
|
||||
link = url_for('support.admin_ticket_detail', ticket_id=ticket.id)
|
||||
title = f'Customer reply on ticket #{ticket.id} from {customer_label}'
|
||||
body = (f'Re: {ticket.subject}\n\n'
|
||||
f'{reply.body[:400]}{"…" if len(reply.body) > 400 else ""}')
|
||||
|
||||
for admin in admins:
|
||||
notify(
|
||||
recipient = admin,
|
||||
title = title,
|
||||
body = body,
|
||||
link = link,
|
||||
send_email = True,
|
||||
)
|
||||
db.session.commit()
|
||||
@@ -0,0 +1,443 @@
|
||||
import logging
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, abort
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.inspection import InspectionTemplate, ChecklistItem
|
||||
from app.utils.forms import InspectionTemplateForm, ChecklistItemForm
|
||||
from app.utils.decorators import supervisor_required
|
||||
import json
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
|
||||
|
||||
bp = Blueprint('templates', __name__, url_prefix='/templates')
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Template CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
templates = InspectionTemplate.query.order_by(InspectionTemplate.name).all()
|
||||
return render_template('templates/list.html', templates=templates)
|
||||
|
||||
|
||||
@bp.route('/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def create_template():
|
||||
form = InspectionTemplateForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
template = InspectionTemplate(
|
||||
name=form.name.data,
|
||||
description=form.description.data,
|
||||
frequency=form.frequency.data,
|
||||
created_by=current_user.id
|
||||
)
|
||||
db.session.add(template)
|
||||
db.session.commit()
|
||||
logger.info('TEMPLATES | create | user=%s | template_id=%s name=%r',
|
||||
current_user.username, template.id, template.name)
|
||||
log_action(ACTION_CREATE, 'Template', template.id, template.name,
|
||||
f'frequency={template.frequency}')
|
||||
|
||||
flash(f'Template "{template.name}" created successfully.', 'success')
|
||||
return redirect(url_for('templates.form_editor', template_id=template.id))
|
||||
|
||||
return render_template('templates/form.html', form=form, title='Create Inspection Template')
|
||||
|
||||
|
||||
@bp.route('/<int:template_id>')
|
||||
@login_required
|
||||
def view_template(template_id):
|
||||
template = db.session.get(InspectionTemplate, template_id)
|
||||
if template is None:
|
||||
abort(404)
|
||||
form_fields = template.get_form_schema()
|
||||
return render_template(
|
||||
'templates/view.html',
|
||||
template=template,
|
||||
form_fields=form_fields
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/<int:template_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def edit_template(template_id):
|
||||
template = db.session.get(InspectionTemplate, template_id)
|
||||
if template is None:
|
||||
abort(404)
|
||||
form = InspectionTemplateForm(obj=template)
|
||||
|
||||
if form.validate_on_submit():
|
||||
template.name = form.name.data
|
||||
template.description = form.description.data
|
||||
template.frequency = form.frequency.data
|
||||
db.session.commit()
|
||||
logger.info('TEMPLATES | edit | user=%s | template_id=%s name=%r',
|
||||
current_user.username, template.id, template.name)
|
||||
log_action(ACTION_UPDATE, 'Template', template.id, template.name,
|
||||
f'frequency={template.frequency}')
|
||||
flash(f'Template "{template.name}" updated successfully.', 'success')
|
||||
return redirect(url_for('templates.view_template', template_id=template.id))
|
||||
|
||||
form_fields = template.get_form_schema()
|
||||
return render_template(
|
||||
'templates/edit.html',
|
||||
form=form,
|
||||
template=template,
|
||||
form_fields=form_fields
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/<int:template_id>/rename', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def rename_template(template_id):
|
||||
template = db.session.get(InspectionTemplate, template_id)
|
||||
if template is None:
|
||||
abort(404)
|
||||
|
||||
new_name = request.form.get('name', '').strip()
|
||||
if not new_name:
|
||||
flash('Template name cannot be empty.', 'danger')
|
||||
return redirect(url_for('templates.index'))
|
||||
if len(new_name) > 255:
|
||||
flash('Template name is too long (max 255 characters).', 'danger')
|
||||
return redirect(url_for('templates.index'))
|
||||
|
||||
valid_frequencies = {'daily', 'weekly', 'monthly', 'quarterly'}
|
||||
new_frequency = request.form.get('frequency', '').strip()
|
||||
if new_frequency not in valid_frequencies:
|
||||
flash('Invalid frequency value.', 'danger')
|
||||
return redirect(url_for('templates.index'))
|
||||
|
||||
template.name = new_name
|
||||
template.description = request.form.get('description', '').strip() or None
|
||||
template.frequency = new_frequency
|
||||
|
||||
db.session.commit()
|
||||
logger.info('TEMPLATES | rename | user=%s | template_id=%s name=%r',
|
||||
current_user.username, template.id, template.name)
|
||||
log_action(ACTION_UPDATE, 'Template', template.id, template.name,
|
||||
f'frequency={template.frequency}; via=rename')
|
||||
flash(f'Template "{template.name}" updated successfully.', 'success')
|
||||
return redirect(url_for('templates.index'))
|
||||
|
||||
|
||||
@bp.route('/<int:template_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def delete_template(template_id):
|
||||
template = db.session.get(InspectionTemplate, template_id)
|
||||
if template is None:
|
||||
abort(404)
|
||||
|
||||
if template.inspections.count() > 0:
|
||||
flash('Cannot delete template with existing inspections.', 'danger')
|
||||
return redirect(url_for('templates.index'))
|
||||
|
||||
template_name = template.name
|
||||
template_id_snap = template.id
|
||||
db.session.delete(template)
|
||||
db.session.commit()
|
||||
logger.info('TEMPLATES | delete | user=%s | template_id=%s name=%r',
|
||||
current_user.username, template_id_snap, template_name)
|
||||
log_action(ACTION_DELETE, 'Template', template_id_snap, template_name)
|
||||
|
||||
flash(f'Template "{template_name}" deleted successfully.', 'success')
|
||||
return redirect(url_for('templates.index'))
|
||||
|
||||
|
||||
@bp.route('/<int:template_id>/toggle-active', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def toggle_active(template_id):
|
||||
template = db.session.get(InspectionTemplate, template_id)
|
||||
if template is None:
|
||||
abort(404)
|
||||
template.active = not template.active
|
||||
db.session.commit()
|
||||
state = 'activated' if template.active else 'deactivated'
|
||||
logger.info('TEMPLATES | toggle_active | user=%s | template_id=%s active=%s',
|
||||
current_user.username, template.id, template.active)
|
||||
log_action(ACTION_UPDATE, 'Template', template.id, template.name, f'active={template.active}')
|
||||
flash(f'Template "{template.name}" {state}.', 'success')
|
||||
return redirect(url_for('templates.index'))
|
||||
|
||||
|
||||
@bp.route('/<int:template_id>/duplicate', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def duplicate_template(template_id):
|
||||
src = db.session.get(InspectionTemplate, template_id)
|
||||
if src is None:
|
||||
abort(404)
|
||||
|
||||
# Duplicate the template header
|
||||
new_tpl = InspectionTemplate(
|
||||
name=f'{src.name} (Copy)',
|
||||
description=src.description,
|
||||
frequency=src.frequency,
|
||||
created_by=current_user.id
|
||||
)
|
||||
db.session.add(new_tpl)
|
||||
db.session.flush() # get new_tpl.id before committing
|
||||
|
||||
# Duplicate all checklist items
|
||||
for item in src.checklist_items.order_by(ChecklistItem.display_order).all():
|
||||
new_item = ChecklistItem(
|
||||
template_id=new_tpl.id,
|
||||
category=item.category,
|
||||
item_description=item.item_description,
|
||||
scoring_type=item.scoring_type,
|
||||
weight=item.weight,
|
||||
requires_photo=item.requires_photo,
|
||||
display_order=item.display_order
|
||||
)
|
||||
db.session.add(new_item)
|
||||
|
||||
# Duplicate form schema if present
|
||||
if src.form_schema:
|
||||
new_tpl.form_schema = src.form_schema
|
||||
|
||||
db.session.commit()
|
||||
logger.info('TEMPLATES | duplicate | user=%s | new_template_id=%s source_id=%s',
|
||||
current_user.username, new_tpl.id, src.id)
|
||||
log_action(ACTION_CREATE, 'Template', new_tpl.id, new_tpl.name,
|
||||
f'duplicated_from={src.id}; frequency={new_tpl.frequency}')
|
||||
|
||||
flash(f'Template "{src.name}" duplicated successfully.', 'success')
|
||||
return redirect(url_for('templates.index'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Form Editor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@bp.route('/<int:template_id>/form-editor')
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def form_editor(template_id):
|
||||
template = db.session.get(InspectionTemplate, template_id)
|
||||
if template is None:
|
||||
abort(404)
|
||||
form_schema = template.get_form_schema()
|
||||
return render_template(
|
||||
'templates/form_editor.html',
|
||||
template=template,
|
||||
form_schema=form_schema # pass the list — tojson handles encoding in the template
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/<int:template_id>/form-editor/save', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def save_form_schema(template_id):
|
||||
"""AJAX endpoint — receives the full form schema as JSON and persists it."""
|
||||
template = db.session.get(InspectionTemplate, template_id)
|
||||
if template is None:
|
||||
abort(404)
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if data is None:
|
||||
return jsonify({'success': False, 'error': 'Invalid JSON payload'}), 400
|
||||
|
||||
fields = data.get('fields', [])
|
||||
|
||||
# Hard cap on total field count to prevent oversized JSON payloads
|
||||
MAX_FIELDS = 150
|
||||
if len(fields) > MAX_FIELDS:
|
||||
return jsonify({'success': False, 'error': f'Form may not exceed {MAX_FIELDS} fields.'}), 400
|
||||
|
||||
# Basic sanitisation — ensure each field has the minimum required keys
|
||||
# Track seen IDs to enforce uniqueness
|
||||
seen_ids = set()
|
||||
sanitised = []
|
||||
for field in fields:
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
if not field.get('id') or not field.get('type'):
|
||||
continue
|
||||
# Reject duplicate field IDs
|
||||
field_id = str(field.get('id', ''))
|
||||
if field_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(field_id)
|
||||
ftype = str(field.get('type', 'text'))
|
||||
entry = {
|
||||
'id': field_id,
|
||||
'type': ftype,
|
||||
'label': str(field.get('label', 'Untitled'))[:255],
|
||||
'placeholder': str(field.get('placeholder', ''))[:255],
|
||||
'required': bool(field.get('required', False)),
|
||||
'options': field.get('options', []) if ftype in ('radio', 'checkbox_group', 'select', 'pass_fail') else [],
|
||||
'help_text': str(field.get('help_text', ''))[:500],
|
||||
'order': int(field.get('order', 0)),
|
||||
# Grid position & size
|
||||
'col': max(1, min(12, int(field.get('col', 1)))),
|
||||
'row': max(1, min(9999, int(field.get('row', 1)))),
|
||||
'colSpan': max(1, min(12, int(field.get('colSpan', 6)))),
|
||||
'rowSpan': max(1, min(20, int(field.get('rowSpan', 2)))),
|
||||
}
|
||||
# Table-specific fields
|
||||
if ftype == 'table':
|
||||
raw_hdrs = field.get('col_headers', ['Column 1', 'Column 2', 'Column 3'])
|
||||
col_headers = [str(h)[:100] for h in raw_hdrs if isinstance(h, str)][:20] or ['Column 1']
|
||||
entry['col_headers'] = col_headers
|
||||
entry['table_cols'] = len(col_headers)
|
||||
entry['table_rows'] = max(1, min(30, int(field.get('table_rows', 3))))
|
||||
# Label-specific fields
|
||||
if ftype == 'label':
|
||||
entry['text_content'] = str(field.get('text_content', 'Label text'))[:2000]
|
||||
entry['font_size'] = field.get('font_size', 'normal') if field.get('font_size') in ('small','normal','large','x-large') else 'normal'
|
||||
entry['font_weight'] = 'bold' if field.get('font_weight') == 'bold' else 'normal'
|
||||
# Button-specific fields
|
||||
if ftype in ('button_submit', 'button_print', 'button_email'):
|
||||
defaults = {'button_submit':'Submit Form','button_print':'Print Form','button_email':'Email Form'}
|
||||
entry['btn_label'] = str(field.get('btn_label', defaults[ftype]))[:100]
|
||||
sanitised.append(entry)
|
||||
|
||||
template.form_schema = sanitised
|
||||
db.session.commit()
|
||||
logger.info('TEMPLATES | save_form_schema | user=%s | template_id=%s fields=%s',
|
||||
current_user.username, template.id, len(sanitised))
|
||||
log_action(ACTION_UPDATE, 'Template', template.id, template.name,
|
||||
f'form_schema saved; field_count={len(sanitised)}')
|
||||
|
||||
return jsonify({'success': True, 'field_count': len(sanitised)})
|
||||
|
||||
|
||||
@bp.route('/<int:template_id>/form-editor/preview')
|
||||
@login_required
|
||||
def form_preview(template_id):
|
||||
"""Renders a read-only preview of the dynamic form."""
|
||||
template = db.session.get(InspectionTemplate, template_id)
|
||||
if template is None:
|
||||
abort(404)
|
||||
form_fields = template.get_form_schema()
|
||||
return render_template(
|
||||
'templates/form_preview.html',
|
||||
template=template,
|
||||
form_fields=form_fields
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Checklist Item Management (legacy, kept for backwards compatibility)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@bp.route('/<int:template_id>/items/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def create_checklist_item(template_id):
|
||||
template = db.session.get(InspectionTemplate, template_id)
|
||||
if template is None:
|
||||
abort(404)
|
||||
form = ChecklistItemForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
max_order = db.session.query(db.func.max(ChecklistItem.display_order))\
|
||||
.filter_by(template_id=template.id).scalar() or 0
|
||||
|
||||
item = ChecklistItem(
|
||||
template_id=template.id,
|
||||
category=form.category.data,
|
||||
item_description=form.item_description.data,
|
||||
scoring_type=form.scoring_type.data,
|
||||
weight=form.weight.data,
|
||||
requires_photo=form.requires_photo.data,
|
||||
display_order=max_order + 1
|
||||
)
|
||||
db.session.add(item)
|
||||
db.session.commit()
|
||||
logger.info('TEMPLATES | create_checklist_item | user=%s | item_id=%s template_id=%s',
|
||||
current_user.username, item.id, template.id)
|
||||
log_action(ACTION_CREATE, 'ChecklistItem', item.id, item.item_description[:80],
|
||||
f'template_id={template.id}; category={item.category or ""}; '
|
||||
f'scoring_type={item.scoring_type}')
|
||||
|
||||
flash('Checklist item added successfully.', 'success')
|
||||
return redirect(url_for('templates.edit_template', template_id=template.id))
|
||||
|
||||
return render_template(
|
||||
'templates/item_form.html',
|
||||
form=form,
|
||||
template=template,
|
||||
title='Add Checklist Item'
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/items/<int:item_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def edit_checklist_item(item_id):
|
||||
item = db.session.get(ChecklistItem, item_id)
|
||||
if item is None:
|
||||
abort(404)
|
||||
form = ChecklistItemForm(obj=item)
|
||||
|
||||
if form.validate_on_submit():
|
||||
item.category = form.category.data
|
||||
item.item_description = form.item_description.data
|
||||
item.scoring_type = form.scoring_type.data
|
||||
item.weight = form.weight.data
|
||||
item.requires_photo = form.requires_photo.data
|
||||
db.session.commit()
|
||||
logger.info('TEMPLATES | edit_checklist_item | user=%s | item_id=%s template_id=%s',
|
||||
current_user.username, item.id, item.template_id)
|
||||
log_action(ACTION_UPDATE, 'ChecklistItem', item.id, item.item_description[:80],
|
||||
f'template_id={item.template_id}; category={item.category or ""}; '
|
||||
f'scoring_type={item.scoring_type}')
|
||||
flash('Checklist item updated successfully.', 'success')
|
||||
return redirect(url_for('templates.edit_template', template_id=item.template_id))
|
||||
|
||||
return render_template(
|
||||
'templates/item_form.html',
|
||||
form=form,
|
||||
item=item,
|
||||
template=item.template,
|
||||
title='Edit Checklist Item'
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/items/<int:item_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def delete_checklist_item(item_id):
|
||||
item = db.session.get(ChecklistItem, item_id)
|
||||
if item is None:
|
||||
abort(404)
|
||||
template_id = item.template_id
|
||||
item_desc = item.item_description[:80]
|
||||
item_id_snap = item.id
|
||||
db.session.delete(item)
|
||||
db.session.commit()
|
||||
logger.info('TEMPLATES | delete_checklist_item | user=%s | item_id=%s template_id=%s',
|
||||
current_user.username, item_id_snap, template_id)
|
||||
log_action(ACTION_DELETE, 'ChecklistItem', item_id_snap, item_desc,
|
||||
f'template_id={template_id}')
|
||||
flash('Checklist item deleted successfully.', 'success')
|
||||
return redirect(url_for('templates.edit_template', template_id=template_id))
|
||||
|
||||
|
||||
@bp.route('/<int:template_id>/items/reorder', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def reorder_items(template_id):
|
||||
template = db.session.get(InspectionTemplate, template_id)
|
||||
if template is None:
|
||||
abort(404)
|
||||
item_order = request.json.get('item_order', [])
|
||||
|
||||
for index, item_id in enumerate(item_order):
|
||||
item = db.session.get(ChecklistItem, item_id)
|
||||
if item and item.template_id == template.id:
|
||||
item.display_order = index
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'success': True})
|
||||
Reference in New Issue
Block a user