Code reviewed and issue fixed.

This commit is contained in:
2026-03-27 14:01:11 -04:00
parent d8828d487a
commit 359c801027
10 changed files with 788 additions and 113 deletions
+229 -25
View File
@@ -11,6 +11,7 @@ from app import db
from app.models import (User, Ticket, Comment, ActivityLog, KnowledgeBase,
KBAttachment, UserRole, TicketStatus)
from app.services.log_service import log_action
from app.services.validation_service import validate_password, validate_file
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
logger = logging.getLogger(__name__)
@@ -73,20 +74,196 @@ def it_required(f):
@login_required
@it_required
def index():
from sqlalchemy import func, case
now = datetime.utcnow()
day7 = now - timedelta(days=7)
day30 = now - timedelta(days=30)
# ── Headline stats ────────────────────────────────────────────────────────
stats = {
'total_tickets' : Ticket.query.count(),
'open' : Ticket.query.filter_by(status=TicketStatus.OPEN).count(),
'in_progress' : Ticket.query.filter_by(status=TicketStatus.IN_PROGRESS).count(),
'resolved' : Ticket.query.filter_by(status=TicketStatus.RESOLVED).count(),
'closed' : Ticket.query.filter_by(status=TicketStatus.CLOSED).count(),
'total_users' : User.query.filter_by(is_active=True).count(),
'employees' : User.query.filter_by(role=UserRole.EMPLOYEE, is_active=True).count(),
'it_staff' : User.query.filter(
User.role.in_([UserRole.IT_STAFF, UserRole.ADMIN]),
User.is_active == True).count(),
'total_tickets' : Ticket.query.count(),
'open' : Ticket.query.filter_by(status=TicketStatus.OPEN).count(),
'in_progress' : Ticket.query.filter_by(status=TicketStatus.IN_PROGRESS).count(),
'resolved' : Ticket.query.filter_by(status=TicketStatus.RESOLVED).count(),
'closed' : Ticket.query.filter_by(status=TicketStatus.CLOSED).count(),
'total_users' : User.query.filter_by(is_active=True).count(),
'employees' : User.query.filter_by(role=UserRole.EMPLOYEE, is_active=True).count(),
'it_staff' : User.query.filter(
User.role.in_([UserRole.IT_STAFF, UserRole.ADMIN]),
User.is_active == True).count(),
}
recent_logs = ActivityLog.query.order_by(ActivityLog.created_at.desc()).limit(20).all()
return render_template('admin/index.html', stats=stats, recent_logs=recent_logs)
# ── IT performance metrics ────────────────────────────────────────────────
# Avg resolution time (hours) — resolved tickets only
resolved_tickets = Ticket.query.filter(
Ticket.resolved_at.isnot(None),
Ticket.created_at.isnot(None),
).all()
if resolved_tickets:
total_hours = sum(
(t.resolved_at - t.created_at).total_seconds() / 3600
for t in resolved_tickets
)
avg_resolution_hours = round(total_hours / len(resolved_tickets), 1)
else:
avg_resolution_hours = None
# Resolved last 7 / 30 days
resolved_7d = Ticket.query.filter(Ticket.resolved_at >= day7).count()
resolved_30d = Ticket.query.filter(Ticket.resolved_at >= day30).count()
# New tickets last 7 / 30 days
new_7d = Ticket.query.filter(Ticket.created_at >= day7).count()
new_30d = Ticket.query.filter(Ticket.created_at >= day30).count()
# Unassigned open tickets
unassigned = Ticket.query.filter(
Ticket.assigned_to_id.is_(None),
Ticket.status.in_([TicketStatus.OPEN, TicketStatus.IN_PROGRESS]),
).count()
# SLA at-risk: open/in-progress tickets older than 3 days
sla_breach = Ticket.query.filter(
Ticket.status.in_([TicketStatus.OPEN, TicketStatus.IN_PROGRESS]),
Ticket.created_at < now - timedelta(days=3),
).count()
# Open tickets by priority
priority_breakdown = {}
for priority in ('critical', 'high', 'medium', 'low'):
priority_breakdown[priority] = Ticket.query.filter(
Ticket.priority == priority,
Ticket.status.in_([TicketStatus.OPEN, TicketStatus.IN_PROGRESS, TicketStatus.PENDING]),
).count()
# Open tickets by category (top 6)
from app.models import TicketCategory
category_breakdown = []
for cat in (TicketCategory.SOFTWARE, TicketCategory.HARDWARE, TicketCategory.NETWORK,
TicketCategory.ACCESS, TicketCategory.EMAIL, TicketCategory.OTHER):
count = Ticket.query.filter(
Ticket.category == cat,
Ticket.status.in_([TicketStatus.OPEN, TicketStatus.IN_PROGRESS]),
).count()
category_breakdown.append((cat.replace('_', ' ').title(), count))
category_breakdown.sort(key=lambda x: x[1], reverse=True)
# ── Staff performance ─────────────────────────────────────────────────────
# Replaced N×5 per-member query loop with two aggregated SQL queries so
# that the dashboard cost is constant regardless of IT team size.
#
# Query 1: grouped counts per assignee (open workload, totals, urgent).
# Query 2: resolution timestamps for avg-hours calculation (one row per
# resolved ticket, grouped in Python to avoid TIMESTAMPDIFF
# dialect differences between MySQL and SQLite).
it_members = User.query.filter(
User.role.in_([UserRole.IT_STAFF, UserRole.ADMIN]),
User.is_active == True,
).order_by(User.full_name).all()
it_member_ids = [m.id for m in it_members]
# ── Aggregated count query ────────────────────────────────────────────────
# Produces one row per assignee with all needed counts computed in SQL.
count_rows = (
db.session.query(
Ticket.assigned_to_id,
func.sum(case(
(Ticket.status.in_([TicketStatus.OPEN, TicketStatus.IN_PROGRESS, TicketStatus.PENDING]), 1),
else_=0,
)).label('assigned_open'),
func.sum(case(
(Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]), 1),
else_=0,
)).label('resolved_total'),
func.sum(case(
(Ticket.resolved_at >= day30, 1),
else_=0,
)).label('resolved_30d'),
func.sum(case(
(
(Ticket.priority.in_(['critical', 'high'])) &
(Ticket.status.in_([TicketStatus.OPEN, TicketStatus.IN_PROGRESS])),
1,
),
else_=0,
)).label('urgent_open'),
)
.filter(Ticket.assigned_to_id.in_(it_member_ids))
.group_by(Ticket.assigned_to_id)
.all()
)
counts_by_id = {row.assigned_to_id: row for row in count_rows}
# ── Resolution-time query ─────────────────────────────────────────────────
# Fetches only the two timestamp columns needed for avg-hours per assignee.
# Using Python arithmetic avoids TIMESTAMPDIFF / strftime dialect issues.
res_rows = (
db.session.query(
Ticket.assigned_to_id,
Ticket.created_at,
Ticket.resolved_at,
)
.filter(
Ticket.assigned_to_id.in_(it_member_ids),
Ticket.resolved_at.isnot(None),
Ticket.created_at.isnot(None),
)
.all()
)
# Group timestamps by assignee in Python for the avg-hours calculation.
res_by_id: dict[int, list[float]] = {}
for row in res_rows:
hours = (row.resolved_at - row.created_at).total_seconds() / 3600
res_by_id.setdefault(row.assigned_to_id, []).append(hours)
# ── Assemble staff_stats from pre-fetched data ────────────────────────────
staff_stats = []
for member in it_members:
row = counts_by_id.get(member.id)
hours = res_by_id.get(member.id, [])
staff_stats.append({
'name' : member.full_name,
'assigned_open' : int(row.assigned_open) if row else 0,
'resolved_total': int(row.resolved_total) if row else 0,
'resolved_30d' : int(row.resolved_30d) if row else 0,
'avg_hours' : round(sum(hours) / len(hours), 1) if hours else None,
'urgent_open' : int(row.urgent_open) if row else 0,
})
# Sort by open workload desc
staff_stats.sort(key=lambda x: x['assigned_open'], reverse=True)
# ── 5 most urgent open tickets ────────────────────────────────────────────
urgent_tickets = Ticket.query.filter(
Ticket.status.in_([TicketStatus.OPEN, TicketStatus.IN_PROGRESS]),
).order_by(
# critical first, then high, then by age
case(
(Ticket.priority == 'critical', 0),
(Ticket.priority == 'high', 1),
(Ticket.priority == 'medium', 2),
else_=3,
),
Ticket.created_at.asc(),
).limit(8).all()
return render_template('admin/index.html',
stats = stats,
avg_resolution_hours= avg_resolution_hours,
resolved_7d = resolved_7d,
resolved_30d = resolved_30d,
new_7d = new_7d,
new_30d = new_30d,
unassigned = unassigned,
sla_breach = sla_breach,
priority_breakdown = priority_breakdown,
category_breakdown = category_breakdown,
staff_stats = staff_stats,
urgent_tickets = urgent_tickets,
now = now,
)
# ─── User Management ─────────────────────────────────────────────────────────
@@ -127,12 +304,9 @@ def create_user():
flash('That username is already taken.', 'danger')
return render_template('admin/create_user.html', roles=_roles(), form=request.form)
if password != confirm:
flash('Passwords do not match.', 'danger')
return render_template('admin/create_user.html', roles=_roles(), form=request.form)
if len(password) < 8:
flash('Password must be at least 8 characters.', 'danger')
pw_error = validate_password(password, confirm)
if pw_error:
flash(pw_error, 'danger')
return render_template('admin/create_user.html', roles=_roles(), form=request.form)
# ── Create ────────────────────────────────────────────────────────────
@@ -147,6 +321,10 @@ def create_user():
)
user.set_password(password)
db.session.add(user)
# Flush to obtain user.id from the DB sequence before logging.
# Without this flush, user.id is None and the activity log entry
# records entity_id=None, making the log entry unlinkable.
db.session.flush()
log_action(current_user.id, 'admin_user_create', 'user', user.id,
f'email={email} role={role}')
db.session.commit()
@@ -172,6 +350,11 @@ def edit_user(user_id):
user.is_active = bool(request.form.get('is_active'))
new_pw = request.form.get('new_password', '')
if new_pw:
confirm_pw = request.form.get('confirm_password', '')
pw_error = validate_password(new_pw, confirm_pw)
if pw_error:
flash(pw_error, 'danger')
return render_template('admin/edit_user.html', user=user, roles=_roles())
user.set_password(new_pw)
logger.info(f'[ADMIN PASSWORD RESET] target_user_id={user.id} by admin_id={current_user.id}')
log_action(current_user.id, 'admin_user_edit', 'user', user.id,
@@ -314,10 +497,13 @@ def kb_upload_image():
if not f or not f.filename:
return jsonify({'error': 'No file provided'}), 400
ext = f.filename.rsplit('.', 1)[-1].lower() if '.' in f.filename else ''
if ext not in {'png', 'jpg', 'jpeg', 'gif', 'webp'}:
return jsonify({'error': 'Only image files are accepted (PNG, JPG, GIF, WEBP)'}), 400
_image_exts = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
file_error = validate_file(f, _image_exts)
if file_error:
logger.warning(f'[KB IMAGE UPLOAD REJECTED] {file_error} filename="{f.filename}" user_id={current_user.id}')
return jsonify({'error': file_error}), 400
ext = f.filename.rsplit('.', 1)[-1].lower()
stored_name = f"{uuid.uuid4().hex}.{ext}"
upload_dir = current_app.config['UPLOAD_FOLDER']
os.makedirs(upload_dir, exist_ok=True)
@@ -329,10 +515,20 @@ def kb_upload_image():
# ── File-serve route (images embedded in articles + attachment downloads) ─────
@admin_bp.route('/kb/files/<path:stored_name>')
@admin_bp.route('/kb/files/<string:stored_name>')
@login_required
def kb_serve_file(stored_name):
"""Serve a KB attachment file. Login required — no public access."""
"""Serve a KB attachment file. Login required — no public access.
Security note
-------------
The <string:> converter is used deliberately instead of <path:>.
The <path:> converter permits forward slashes in the captured segment,
which would allow a crafted URL like /kb/files/../../etc/passwd to
traverse outside the upload directory. <string:> disallows slashes,
restricting the value to a flat filename — matching the UUID-based
stored_name format (e.g. 'a1b2c3d4e5f6....png') used by all upload helpers.
"""
upload_dir = current_app.config['UPLOAD_FOLDER']
return send_from_directory(upload_dir, stored_name)
@@ -400,7 +596,11 @@ def kb_new():
db.session.flush()
for f in request.files.getlist('attachments'):
if f and f.filename and _kb_allowed(f.filename):
if f and f.filename:
file_error = validate_file(f, _KB_ALLOWED_EXT)
if file_error:
logger.warning(f'[KB NEW UPLOAD REJECTED] {file_error} filename="{f.filename}" user_id={current_user.id}')
continue
att = _save_kb_file(f, article.id)
db.session.add(att)
@@ -433,7 +633,11 @@ def kb_edit(article_id):
article.is_published= bool(request.form.get('is_published')) and not bool(request.form.get('_save_as_draft'))
for f in request.files.getlist('attachments'):
if f and f.filename and _kb_allowed(f.filename):
if f and f.filename:
file_error = validate_file(f, _KB_ALLOWED_EXT)
if file_error:
logger.warning(f'[KB EDIT UPLOAD REJECTED] {file_error} filename="{f.filename}" user_id={current_user.id}')
continue
att = _save_kb_file(f, article.id)
db.session.add(att)
+27 -24
View File
@@ -6,6 +6,7 @@ from flask_login import login_user, logout_user, login_required, current_user
from app import db, limiter
from app.models import User, UserRole
from app.services.log_service import log_action
from app.services.validation_service import validate_password
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
logger = logging.getLogger(__name__)
@@ -75,26 +76,30 @@ def register():
flash('Email already registered.', 'danger')
elif User.query.filter_by(username=username).first():
flash('Username already taken.', 'danger')
elif password != confirm:
flash('Passwords do not match.', 'danger')
elif len(password) < 8:
flash('Password must be at least 8 characters.', 'danger')
else:
user = User(
email = email,
username = username,
full_name = full_name,
department = department,
phone = phone,
role = UserRole.EMPLOYEE,
)
user.set_password(password)
db.session.add(user)
log_action(user.id, 'user_register', 'user', user.id, f'email={email}')
db.session.commit()
logger.info(f'[AUTH REGISTER] user_id={user.id} email={email}')
flash('Account created! You may now log in.', 'success')
return redirect(url_for('auth.login'))
pw_error = validate_password(password, confirm)
if pw_error:
flash(pw_error, 'danger')
else:
user = User(
email = email,
username = username,
full_name = full_name,
department = department,
phone = phone,
role = UserRole.EMPLOYEE,
)
user.set_password(password)
db.session.add(user)
# Flush to obtain user.id from the DB sequence before logging.
# Without this flush, user.id is None and the activity log entry
# records entity_id=None, making the log entry unlinkable.
db.session.flush()
log_action(user.id, 'user_register', 'user', user.id, f'email={email}')
db.session.commit()
logger.info(f'[AUTH REGISTER] user_id={user.id} email={email}')
flash('Account created! You may now log in.', 'success')
return redirect(url_for('auth.login'))
return render_template('auth/register.html')
@@ -128,11 +133,9 @@ def profile():
current_user.web_notif = web_notif
if new_pw:
if new_pw != confirm_pw:
flash('Passwords do not match.', 'danger')
return render_template('auth/profile.html')
if len(new_pw) < 8:
flash('Password must be at least 8 characters.', 'danger')
pw_error = validate_password(new_pw, confirm_pw)
if pw_error:
flash(pw_error, 'danger')
return render_template('auth/profile.html')
current_user.set_password(new_pw)
logger.info(f'[AUTH PASSWORD CHANGE] user_id={current_user.id}')
+6 -1
View File
@@ -3,7 +3,7 @@ import logging
import requests
from flask import Blueprint, request, jsonify, current_app
from flask_login import login_required, current_user
from app import db
from app import db, limiter
from app.models import Ticket, TicketStatus, TicketPriority, TicketCategory
from app.services.notification_service import notify_new_ticket
from app.services.log_service import log_action
@@ -79,6 +79,7 @@ def _call_groq(api_key, history, user_msg):
@chatbot_bp.route('/message', methods=['POST'])
@login_required
@limiter.limit('20 per minute; 100 per hour')
def chat():
data = request.get_json(force=True)
history = data.get('history', []) # [{role, content}, ...]
@@ -146,6 +147,10 @@ def chat():
)
ticket.ticket_number = ticket.generate_ticket_number()
db.session.add(ticket)
# Flush to obtain ticket.id from the DB sequence before logging.
# Without this flush, ticket.id is None and the activity log
# entry records entity_id=None, making the log entry unlinkable.
db.session.flush()
log_action(current_user.id, 'ticket_create_chatbot', 'ticket', ticket.id,
f'ticket_number={ticket.ticket_number} ai_generated=True')
db.session.commit()
+47 -9
View File
@@ -15,17 +15,16 @@ from app.services.notification_service import (
notify_comment_added, notify_assignment,
)
from app.services.log_service import log_action, log_ticket_history
from app.services.validation_service import validate_file
tickets_bp = Blueprint('tickets', __name__)
logger = logging.getLogger(__name__)
# Allowed extensions for ticket and comment attachments.
# validate_file() uses this set for both extension and magic-byte checks.
ALLOWED_EXT = {'png', 'jpg', 'jpeg', 'gif', 'pdf', 'doc', 'docx', 'txt', 'zip', 'log'}
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXT
def save_attachment(file, ticket_id=None, comment_id=None, uploader_id=None):
filename = secure_filename(file.filename)
ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
@@ -114,7 +113,11 @@ def create_ticket():
# Handle file uploads
for f in request.files.getlist('attachments'):
if f and f.filename and allowed_file(f.filename):
if f and f.filename:
file_error = validate_file(f, ALLOWED_EXT)
if file_error:
logger.warning(f'[TICKET UPLOAD REJECTED] {file_error} filename="{f.filename}" user_id={current_user.id}')
continue
save_attachment(f, ticket_id=ticket.id, uploader_id=current_user.id)
log_action(current_user.id, 'ticket_create', 'ticket', ticket.id,
@@ -193,7 +196,11 @@ def ticket_detail(ticket_id):
db.session.flush()
for f in request.files.getlist('attachments'):
if f and f.filename and allowed_file(f.filename):
if f and f.filename:
file_error = validate_file(f, ALLOWED_EXT)
if file_error:
logger.warning(f'[COMMENT UPLOAD REJECTED] {file_error} filename="{f.filename}" user_id={current_user.id}')
continue
save_attachment(f, ticket_id=ticket.id,
comment_id=comment.id, uploader_id=current_user.id)
@@ -266,7 +273,7 @@ def update_ticket(ticket_id):
def _user_label(uid):
if uid is None:
return 'Unassigned'
u = User.query.get(uid)
u = db.session.get(User, uid)
return u.full_name if u else f'User #{uid}'
ticket.assigned_to_id = new_assigned
@@ -275,7 +282,7 @@ def update_ticket(ticket_id):
_user_label(new_assigned),
current_user.id)
changes.append(f'assigned_to: {old_assigned}{new_assigned}')
notify_assignment(ticket, current_user)
# notify_assignment is called AFTER commit below — see Fix #13.
ticket.internal_notes = internal_notes
ticket.resolution_notes = resolution
@@ -291,6 +298,13 @@ def update_ticket(ticket_id):
db.session.commit()
logger.info(f'[TICKET UPDATE] ticket_id={ticket.id} changes={changes} by user_id={current_user.id}')
# Both notification calls are placed after commit so that create_notification's
# independent commit never races against an uncommitted ticket state. If the
# parent commit above had failed, neither notification would be sent — which
# is the correct behaviour (no notification for a change that did not persist).
if new_assigned != old_assigned:
notify_assignment(ticket, current_user)
if new_status != old_status:
notify_status_change(ticket, old_status, current_user)
@@ -322,6 +336,18 @@ def delete_comment(comment_id):
@login_required
def download_attachment(att_id):
att = Attachment.query.get_or_404(att_id)
# Authorization: employees may only download attachments belonging to
# their own tickets. IT staff have unrestricted access across all tickets.
# att.ticket_id is the authoritative link — comment attachments also carry
# the parent ticket_id, so this check covers both ticket and comment files.
if not current_user.is_it_staff:
ticket = Ticket.query.get_or_404(att.ticket_id)
if ticket.created_by_id != current_user.id:
logger.warning(
f'[ATTACHMENT ACCESS DENIED] att_id={att_id} ticket_id={att.ticket_id} '
f'user_id={current_user.id}'
)
abort(403)
upload_dir = current_app.config['UPLOAD_FOLDER']
return send_from_directory(upload_dir, att.stored_name, as_attachment=True,
download_name=att.filename)
@@ -385,8 +411,20 @@ def knowledge_base():
@login_required
def kb_article(article_id):
article = KnowledgeBase.query.get_or_404(article_id)
article.view_count += 1
# Increment view_count atomically at the SQL level. A Python-level
# read-modify-write (article.view_count += 1) is not safe under concurrent
# requests: two simultaneous reads both see the same value and one
# increment is silently lost. The SQL expression KnowledgeBase.view_count + 1
# delegates the addition to the database, which serialises it correctly.
from sqlalchemy import update as sa_update
db.session.execute(
sa_update(KnowledgeBase)
.where(KnowledgeBase.id == article_id)
.values(view_count=KnowledgeBase.view_count + 1)
)
db.session.commit()
# Re-fetch so the template receives the post-increment value.
db.session.refresh(article)
return render_template('tickets/kb_article.html', article=article)