Code reviewed and issue fixed.
This commit is contained in:
+4
-1
@@ -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
@@ -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
@@ -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
@@ -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}')
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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}')
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 (>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 %}
|
||||
Reference in New Issue
Block a user