First commit

This commit is contained in:
2026-06-26 09:04:34 -04:00
commit 77678ed724
166 changed files with 34842 additions and 0 deletions
View File
+150
View File
@@ -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'))
+519
View File
@@ -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)
+140
View File
@@ -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'))
+785
View File
@@ -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])
+427
View File
@@ -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)
+238
View File
@@ -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
+1081
View File
File diff suppressed because it is too large Load Diff
+308
View File
@@ -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})
+668
View File
@@ -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
+524
View File
@@ -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})
+371
View File
@@ -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()
+443
View File
@@ -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})