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
+4 -1
View File
@@ -112,7 +112,10 @@ def create_app(config_name=None):
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
# db.session.get() is the SQLAlchemy 2.x successor to the deprecated
# Model.query.get(). Both hit the identity map first (no SQL if the
# object is already in session), then fall back to a SELECT by PK.
return db.session.get(User, int(user_id))
# ── Context processors ────────────────────────────────────────────────────
@app.context_processor
+27 -4
View File
@@ -124,11 +124,34 @@ class Ticket(db.Model):
history = db.relationship('TicketHistory', backref='ticket', lazy='dynamic', cascade='all, delete-orphan')
def generate_ticket_number(self):
"""Generate unique ticket number like TKT-20240101-0001"""
"""Generate a unique ticket number like TKT-20240101-0001.
Concurrency safety
------------------
The naive approach — query the last ticket, increment its sequence,
then insert — has a TOCTOU race: two concurrent requests can both read
the same "last" ticket and generate the same next number, causing an
IntegrityError at commit time.
We eliminate the race by appending FOR UPDATE to the SELECT. This
acquires a row-level write lock on the last matching ticket for the
duration of the current transaction, serialising concurrent callers
through the database rather than through application code. The lock is
released automatically when the transaction commits or rolls back.
MySQL / MariaDB: with_for_update() emits SELECT … FOR UPDATE.
SQLite (dev/test): SELECT … FOR UPDATE is silently ignored, which is
acceptable because SQLite's connection-level locking already prevents
concurrent writes in practice.
"""
date_str = datetime.utcnow().strftime('%Y%m%d')
last = Ticket.query.filter(
Ticket.ticket_number.like(f'TKT-{date_str}-%')
).order_by(Ticket.id.desc()).first()
last = (
Ticket.query
.filter(Ticket.ticket_number.like(f'TKT-{date_str}-%'))
.order_by(Ticket.id.desc())
.with_for_update() # acquires a write lock; serialises concurrent callers
.first()
)
if last:
seq = int(last.ticket_number.split('-')[-1]) + 1
else:
+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)
+31 -4
View File
@@ -54,8 +54,16 @@ def log_action(user_id, action, entity_type=None, entity_id=None, details=None):
own business objects. This ensures the log entry is only persisted when
the parent operation succeeds an independent commit here would leave
orphaned log entries for operations that were subsequently rolled back.
Error isolation
---------------
On failure, only the log entry itself is expelled from the session via
expunge(). db.session.rollback() is intentionally NOT called here because
that would wipe the entire session silently undoing the parent business
operation (ticket creation, user update, etc.) that triggered this log call.
"""
ip = _get_real_ip()
entry = None
try:
entry = ActivityLog(
@@ -74,8 +82,14 @@ def log_action(user_id, action, entity_type=None, entity_id=None, details=None):
f'user_id={user_id} ip={ip}'
)
except Exception as exc:
db.session.rollback()
logger.error(f'[ACTIVITY LOG ERROR] {exc}')
# Expunge only the failed log entry — do NOT roll back the full session,
# as that would undo the parent operation that called this function.
if entry is not None:
try:
db.session.expunge(entry)
except Exception:
pass
logger.error(f'[ACTIVITY LOG ERROR] action={action} entity={entity_type}:{entity_id} error={exc}')
def log_ticket_history(ticket, field_name, old_value, new_value, changed_by_id):
@@ -85,8 +99,15 @@ def log_ticket_history(ticket, field_name, old_value, new_value, changed_by_id):
----------------
Like log_action, this function does NOT commit the caller is responsible
for committing the session after all field changes have been recorded.
Error isolation
---------------
On failure, only the failed history entry is expelled from the session.
db.session.rollback() is intentionally NOT called here that would undo
the parent ticket update that triggered this history recording.
"""
from app.models import TicketHistory
entry = None
try:
entry = TicketHistory(
ticket_id = ticket.id,
@@ -102,5 +123,11 @@ def log_ticket_history(ticket, field_name, old_value, new_value, changed_by_id):
f'"{old_value}" -> "{new_value}" by user_id={changed_by_id}'
)
except Exception as exc:
db.session.rollback()
logger.error(f'[TICKET HISTORY ERROR] {exc}')
# Expunge only the failed history entry — do NOT roll back the full
# session, as that would undo the parent ticket update.
if entry is not None:
try:
db.session.expunge(entry)
except Exception:
pass
logger.error(f'[TICKET HISTORY ERROR] ticket_id={ticket.id} field={field_name} error={exc}')
+1 -1
View File
@@ -251,7 +251,7 @@ def notify_comment_added(comment):
ticket_id = ticket.id,
link = f'/tickets/{ticket.id}#comment-{comment.id}',
)
user = User.query.get(user_id)
user = db.session.get(User, user_id)
if user and user.email_notif and not is_internal:
html = render_template_string(_STATUS_UPDATE_EMAIL,
ticket_number = ticket.ticket_number,
+179
View File
@@ -0,0 +1,179 @@
"""
Shared validation helpers used across multiple route modules.
Centralising these here prevents logic divergence that arises from
copy-pasting validation code into auth.py, admin.py, and future modules.
"""
import logging
logger = logging.getLogger(__name__)
# ─── Password Validation ──────────────────────────────────────────────────────
def validate_password(password: str, confirm: str) -> str | None:
"""Validate a new password and its confirmation field.
Returns an error message string if validation fails, or None if the
password is acceptable. Callers should flash the returned message with
the 'danger' category and return early.
Rules
-----
- Password and confirmation must match.
- Minimum length: 8 characters.
Parameters
----------
password : str the candidate password (plain text)
confirm : str the confirmation field value
"""
if password != confirm:
return 'Passwords do not match.'
if len(password) < 8:
return 'Password must be at least 8 characters.'
return None
# ─── File MIME-Type Validation ────────────────────────────────────────────────
# Magic-byte signatures for every extension in ALLOWED_EXT / _KB_ALLOWED_EXT.
# Format: extension → list of (offset, bytes) tuples that must ALL be present.
# Using raw magic bytes avoids a dependency on python-magic / libmagic while
# still catching the "renamed shell.php → shell.pdf" class of attack.
#
# References: https://en.wikipedia.org/wiki/List_of_file_signatures
_MAGIC: dict[str, list[tuple[int, bytes]]] = {
'png' : [(0, b'\x89PNG\r\n\x1a\n')],
'jpg' : [(0, b'\xff\xd8\xff')],
'jpeg': [(0, b'\xff\xd8\xff')],
'gif' : [(0, b'GIF87a'), (0, b'GIF89a')], # either signature is valid
'webp': [(0, b'RIFF'), (8, b'WEBP')],
'pdf' : [(0, b'%PDF')],
'zip' : [(0, b'PK\x03\x04')],
# Office Open XML (.docx, .xlsx, .pptx) are ZIP archives internally
'docx': [(0, b'PK\x03\x04')],
'xlsx': [(0, b'PK\x03\x04')],
'pptx': [(0, b'PK\x03\x04')],
# Legacy OLE2 compound document (.doc, .xls, .ppt)
'doc' : [(0, b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1')],
'xls' : [(0, b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1')],
'ppt' : [(0, b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1')],
# Plain-text formats — no reliable magic bytes; skip byte check
'txt' : [],
'csv' : [],
'log' : [],
}
# Maximum number of bytes to read for magic-byte inspection.
# 12 bytes covers all signatures above (WEBP needs offset 8 + 4 = 12).
_MAGIC_READ_BYTES = 12
# Per-file size cap (bytes). Mirrors MAX_CONTENT_LENGTH so a single
# large file cannot saturate the total-request budget on its own.
_MAX_FILE_BYTES = 16 * 1024 * 1024 # 16 MB
def validate_file(file, allowed_extensions: set[str]) -> str | None:
"""Validate an uploaded file object by extension, size, and magic bytes.
Returns an error message string if validation fails, or None on success.
The file stream seek position is reset to 0 after inspection so callers
can still read or save the file normally.
Parameters
----------
file : werkzeug FileStorage object
allowed_extensions : set of lowercase extension strings (without leading dot)
Checks performed
----------------
1. Extension is in the allowed set.
2. File does not exceed the per-file size cap (_MAX_FILE_BYTES).
3. Magic bytes in the file content match the declared extension, where
a known signature exists. Plain-text types (txt, csv, log) are
accepted on extension alone since they have no reliable magic bytes.
"""
filename = file.filename or ''
if '.' not in filename:
return 'File has no extension.'
ext = filename.rsplit('.', 1)[1].lower()
# ── 1. Extension check ────────────────────────────────────────────────────
if ext not in allowed_extensions:
return f'File type ".{ext}" is not permitted.'
# ── 2. Size check ─────────────────────────────────────────────────────────
# Read the file in chunks to measure size without loading it all into RAM,
# then seek back to the start so the caller can still save it.
file.stream.seek(0, 2) # seek to end
file_size = file.stream.tell() # position == size
file.stream.seek(0) # rewind
if file_size > _MAX_FILE_BYTES:
mb = _MAX_FILE_BYTES // (1024 * 1024)
return f'File exceeds the {mb} MB per-file size limit.'
# ── 3. Magic-byte check ───────────────────────────────────────────────────
signatures = _MAGIC.get(ext)
if signatures is None:
# Extension is allowed but has no entry in _MAGIC — treat as safe.
# This handles future extensions added to ALLOWED_EXT without a
# corresponding _MAGIC entry; log a warning for visibility.
logger.warning(f'[FILE VALIDATION] No magic signature defined for ext="{ext}"; skipping byte check.')
return None
if not signatures:
# Explicit empty list means "no magic bytes available for this type"
# (txt, csv, log) — accepted on extension alone.
return None
header = file.stream.read(_MAGIC_READ_BYTES)
file.stream.seek(0) # rewind after inspection
# For extensions with multiple valid signatures (e.g. GIF87a / GIF89a)
# the file is valid if ANY of the listed signatures matches.
matched = False
for sigs in _group_by_alternative(signatures):
# Each alternative is a list of (offset, bytes) pairs that must ALL match.
if all(header[offset:offset + len(magic)] == magic for offset, magic in sigs):
matched = True
break
if not matched:
logger.warning(
f'[FILE VALIDATION] Magic-byte mismatch: filename="{filename}" ext="{ext}" '
f'header={header.hex()}'
)
return f'File content does not match its declared type (.{ext}).'
return None
def _group_by_alternative(
signatures: list[tuple[int, bytes]],
) -> list[list[tuple[int, bytes]]]:
"""Split a flat signature list into per-alternative groups.
For most extensions there is a single signature, so this returns
[[sig1, sig2, ...]]. For GIF (two valid headers) it returns
[[gif87_sig], [gif89_sig]] so the caller can treat each inner list as
a complete match candidate.
The rule: each entry with offset=0 starts a new alternative group.
Entries with offset>0 are appended to the current group (they are
additional constraints on the same file type, e.g. WEBP needs both
offset-0 'RIFF' and offset-8 'WEBP').
"""
groups: list[list[tuple[int, bytes]]] = []
for offset, magic in signatures:
if offset == 0:
groups.append([(offset, magic)])
else:
if groups:
groups[-1].append((offset, magic))
else:
groups.append([(offset, magic)])
return groups if groups else [[]]
+236 -43
View File
@@ -3,15 +3,16 @@
{% block page_title %}IT Operations Overview{% endblock %}
{% block content %}
<!-- Stats grid -->
{# ── Headline stat cards ───────────────────────────────────────────────────── #}
<div class="row g-3 mb-4">
{% for label, value, icon, color, bg in [
('Total Tickets', stats.total_tickets, 'bi-collection', 'var(--text)', 'rgba(255,255,255,.05)'),
('Open', stats.open, 'bi-circle', 'var(--info)', 'rgba(96,165,250,.1)'),
('In Progress', stats.in_progress, 'bi-arrow-repeat', 'var(--accent3)', 'rgba(0,180,216,.1)'),
('Resolved', stats.resolved, 'bi-check-circle', 'var(--success)', 'rgba(45,212,191,.1)'),
('Closed', stats.closed, 'bi-archive', 'var(--muted)', 'rgba(112,112,160,.1)'),
('Active Users', stats.total_users, 'bi-people', 'var(--accent2)', 'rgba(123,94,167,.1)'),
('Total Tickets', stats.total_tickets, 'bi-collection', 'var(--text)', 'rgba(255,255,255,.05)'),
('Open', stats.open, 'bi-circle', 'var(--info)', 'rgba(96,165,250,.1)'),
('In Progress', stats.in_progress, 'bi-arrow-repeat','var(--accent3)','rgba(0,180,216,.1)'),
('Resolved', stats.resolved, 'bi-check-circle','var(--success)','rgba(45,212,191,.1)'),
('Closed', stats.closed, 'bi-archive', 'var(--muted)', 'rgba(112,112,160,.1)'),
('Active Users', stats.total_users, 'bi-people', 'var(--accent2)','rgba(123,94,167,.1)'),
] %}
<div class="col-6 col-xl-2">
<div class="stat-card">
@@ -25,44 +26,236 @@
{% endfor %}
</div>
<!-- Activity log -->
<div class="card">
<div class="card-header d-flex align-items-center justify-content-between">
<span><i class="bi bi-activity me-2"></i>Recent Activity</span>
{% if current_user.is_admin %}
<a href="{{ url_for('admin.activity_logs') }}" style="font-size:12px;color:var(--accent3);">Full log →</a>
{% endif %}
{# ── Row 1: Performance KPIs + Priority breakdown ─────────────────────────── #}
<div class="row g-3 mb-4">
{# KPI cards #}
<div class="col-lg-8">
<div class="card h-100">
<div class="card-header"><i class="bi bi-speedometer2 me-2"></i>IT Performance — Last 30 Days</div>
<div class="card-body">
<div class="row g-3">
{# Avg resolution time #}
<div class="col-6 col-lg-3">
<div style="text-align:center;padding:12px;background:var(--surface2);border-radius:10px;">
<div style="font-size:26px;font-weight:700;
color:{% if avg_resolution_hours is none %}var(--muted)
{% elif avg_resolution_hours <= 24 %}var(--success)
{% elif avg_resolution_hours <= 72 %}var(--warning)
{% else %}var(--danger){% endif %};">
{% if avg_resolution_hours is none %}—
{% elif avg_resolution_hours >= 24 %}{{ (avg_resolution_hours / 24)|round(1) }}d
{% else %}{{ avg_resolution_hours }}h{% endif %}
</div>
<div style="font-size:11px;color:var(--muted);margin-top:4px;">Avg Resolution Time</div>
</div>
</div>
{# Resolved last 7d #}
<div class="col-6 col-lg-3">
<div style="text-align:center;padding:12px;background:var(--surface2);border-radius:10px;">
<div style="font-size:26px;font-weight:700;color:var(--success);">{{ resolved_7d }}</div>
<div style="font-size:11px;color:var(--muted);margin-top:4px;">Resolved (7 days)</div>
</div>
</div>
{# New last 7d #}
<div class="col-6 col-lg-3">
<div style="text-align:center;padding:12px;background:var(--surface2);border-radius:10px;">
<div style="font-size:26px;font-weight:700;color:var(--info);">{{ new_7d }}</div>
<div style="font-size:11px;color:var(--muted);margin-top:4px;">New (7 days)</div>
</div>
</div>
{# Resolution rate 30d #}
<div class="col-6 col-lg-3">
{% set rate = ((resolved_30d / new_30d * 100)|int) if new_30d > 0 else 0 %}
<div style="text-align:center;padding:12px;background:var(--surface2);border-radius:10px;">
<div style="font-size:26px;font-weight:700;
color:{% if rate >= 80 %}var(--success){% elif rate >= 50 %}var(--warning){% else %}var(--danger){% endif %};">
{{ rate }}%
</div>
<div style="font-size:11px;color:var(--muted);margin-top:4px;">Resolution Rate (30d)</div>
</div>
</div>
{# Unassigned #}
<div class="col-6 col-lg-3">
<a href="{{ url_for('admin.all_tickets', assigned='unassigned') }}" style="text-decoration:none;">
<div style="text-align:center;padding:12px;background:var(--surface2);border-radius:10px;cursor:pointer;">
<div style="font-size:26px;font-weight:700;color:{% if unassigned > 0 %}var(--warning){% else %}var(--success){% endif %};">
{{ unassigned }}
</div>
<div style="font-size:11px;color:var(--muted);margin-top:4px;">Unassigned Open</div>
</div>
</a>
</div>
{# SLA at-risk #}
<div class="col-6 col-lg-3">
<div style="text-align:center;padding:12px;background:var(--surface2);border-radius:10px;">
<div style="font-size:26px;font-weight:700;color:{% if sla_breach > 0 %}var(--danger){% else %}var(--success){% endif %};">
{{ sla_breach }}
</div>
<div style="font-size:11px;color:var(--muted);margin-top:4px;">SLA At-Risk (&gt;3 days)</div>
</div>
</div>
{# Resolved 30d #}
<div class="col-6 col-lg-3">
<div style="text-align:center;padding:12px;background:var(--surface2);border-radius:10px;">
<div style="font-size:26px;font-weight:700;color:var(--success);">{{ resolved_30d }}</div>
<div style="font-size:11px;color:var(--muted);margin-top:4px;">Resolved (30 days)</div>
</div>
</div>
{# New 30d #}
<div class="col-6 col-lg-3">
<div style="text-align:center;padding:12px;background:var(--surface2);border-radius:10px;">
<div style="font-size:26px;font-weight:700;color:var(--info);">{{ new_30d }}</div>
<div style="font-size:11px;color:var(--muted);margin-top:4px;">New (30 days)</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card-body p-0">
<table class="table mb-0">
<thead>
<tr>
<th>Action</th>
<th>User</th>
<th>Entity</th>
<th>Details</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{% for log in recent_logs %}
<tr>
<td>
<span class="mono" style="font-size:11px;
color:{% if 'create' in log.action %}var(--success){% elif 'delete' in log.action %}var(--danger){% elif 'update' in log.action or 'edit' in log.action %}var(--warning){% else %}var(--muted){% endif %};">
{{ log.action }}
</span>
</td>
<td style="font-size:13px;">
{% if log.user %}{{ log.user.full_name }}{% else %}<span style="color:var(--muted);">System</span>{% endif %}
</td>
<td style="font-size:12px;color:var(--muted);">{{ log.entity_type or '—' }} {% if log.entity_id %}#{{ log.entity_id }}{% endif %}</td>
<td style="font-size:12px;color:var(--muted);max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ log.details or '—' }}</td>
<td style="font-size:11px;color:var(--muted);font-family:'Space Mono',monospace;">{{ log.created_at.strftime('%b %d %H:%M') }}</td>
</tr>
{# Priority breakdown #}
<div class="col-lg-4">
<div class="card h-100">
<div class="card-header"><i class="bi bi-bar-chart me-2"></i>Open by Priority</div>
<div class="card-body">
{% set total_open = priority_breakdown.values()|sum %}
{% for priority, count in [
('critical', priority_breakdown.critical),
('high', priority_breakdown.high),
('medium', priority_breakdown.medium),
('low', priority_breakdown.low),
] %}
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="badge badge-{{ priority }}" style="font-size:11px;">{{ priority.upper() }}</span>
<span style="font-size:13px;font-weight:600;">{{ count }}</span>
</div>
<div style="height:6px;background:var(--surface2);border-radius:3px;overflow:hidden;">
<div style="height:100%;width:{{ ((count / total_open * 100)|int) if total_open > 0 else 0 }}%;
background:{% if priority=='critical' %}#374151{% elif priority=='high' %}var(--danger){% elif priority=='medium' %}var(--warning){% else %}var(--success){% endif %};
border-radius:3px;transition:width .4s;"></div>
</div>
</div>
{% endfor %}
</tbody>
</table>
<hr style="border-color:var(--border);margin:12px 0;">
<div style="font-size:12px;color:var(--muted);">Open by Category</div>
<div class="mt-2">
{% for cat, count in category_breakdown %}
{% if count > 0 %}
<div class="d-flex justify-content-between" style="font-size:12px;padding:3px 0;border-bottom:1px solid var(--border);">
<span style="color:var(--muted);">{{ cat }}</span>
<span style="font-weight:600;">{{ count }}</span>
</div>
{% endif %}
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{# ── Row 2: Staff performance + Urgent queue ──────────────────────────────── #}
<div class="row g-3">
{# Staff performance table #}
<div class="col-lg-7">
<div class="card">
<div class="card-header"><i class="bi bi-people me-2"></i>IT Staff Performance</div>
<div class="card-body p-0">
{% if staff_stats %}
<table class="table mb-0">
<thead>
<tr>
<th>Staff Member</th>
<th style="text-align:center;">Open</th>
<th style="text-align:center;">Urgent</th>
<th style="text-align:center;">Resolved (30d)</th>
<th style="text-align:center;">Avg Time</th>
<th style="text-align:center;">Total Resolved</th>
</tr>
</thead>
<tbody>
{% for s in staff_stats %}
<tr>
<td style="font-size:13px;font-weight:500;">{{ s.name }}</td>
<td style="text-align:center;">
<span style="font-size:13px;font-weight:600;color:{% if s.assigned_open > 5 %}var(--danger){% elif s.assigned_open > 2 %}var(--warning){% else %}var(--success){% endif %};">
{{ s.assigned_open }}
</span>
</td>
<td style="text-align:center;">
{% if s.urgent_open > 0 %}
<span style="font-size:12px;font-weight:600;color:var(--danger);">{{ s.urgent_open }}</span>
{% else %}
<span style="font-size:12px;color:var(--success);"><i class="bi bi-check2"></i></span>
{% endif %}
</td>
<td style="text-align:center;font-size:13px;color:var(--success);font-weight:600;">{{ s.resolved_30d }}</td>
<td style="text-align:center;font-size:12px;color:var(--muted);">
{% if s.avg_hours is none %}—
{% elif s.avg_hours >= 24 %}{{ (s.avg_hours / 24)|round(1) }}d
{% else %}{{ s.avg_hours }}h{% endif %}
</td>
<td style="text-align:center;font-size:12px;color:var(--muted);">{{ s.resolved_total }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="p-4 text-center" style="color:var(--muted);font-size:13px;">No IT staff found.</div>
{% endif %}
</div>
</div>
</div>
{# Urgent open tickets queue #}
<div class="col-lg-5">
<div class="card">
<div class="card-header d-flex align-items-center justify-content-between">
<span><i class="bi bi-exclamation-triangle me-2"></i>Needs Attention</span>
<a href="{{ url_for('admin.all_tickets', status='open') }}" style="font-size:12px;color:var(--accent3);">View all →</a>
</div>
<div class="card-body p-0">
{% if urgent_tickets %}
{% for t in urgent_tickets %}
<a href="{{ url_for('tickets.ticket_detail', ticket_id=t.id) }}"
style="display:flex;align-items:center;gap:10px;padding:10px 16px;border-bottom:1px solid var(--border);color:var(--text);text-decoration:none;">
<span class="badge badge-{{ t.priority }}" style="flex-shrink:0;font-size:10px;">{{ t.priority.upper() }}</span>
<div style="flex:1;min-width:0;">
<div style="font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ t.title }}</div>
<div style="font-size:11px;color:var(--muted);">
{{ t.ticket_number }} · {{ t.creator.full_name.split()[0] }} ·
{% set age = ((now - t.created_at).total_seconds() / 3600)|int %}
{% if age >= 24 %}{{ (age / 24)|int }}d ago{% else %}{{ age }}h ago{% endif %}
</div>
</div>
{% if t.assignee %}
<span style="font-size:11px;color:var(--muted);flex-shrink:0;">{{ t.assignee.full_name.split()[0] }}</span>
{% else %}
<span style="font-size:11px;color:var(--warning);flex-shrink:0;font-style:italic;">Unassigned</span>
{% endif %}
</a>
{% endfor %}
{% else %}
<div class="p-4 text-center" style="color:var(--success);font-size:13px;">
<i class="bi bi-check2-all" style="font-size:28px;display:block;margin-bottom:8px;"></i>
All caught up — no urgent open tickets.
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}