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
|
@login_manager.user_loader
|
||||||
def load_user(user_id):
|
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 ────────────────────────────────────────────────────
|
# ── Context processors ────────────────────────────────────────────────────
|
||||||
@app.context_processor
|
@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')
|
history = db.relationship('TicketHistory', backref='ticket', lazy='dynamic', cascade='all, delete-orphan')
|
||||||
|
|
||||||
def generate_ticket_number(self):
|
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')
|
date_str = datetime.utcnow().strftime('%Y%m%d')
|
||||||
last = Ticket.query.filter(
|
last = (
|
||||||
Ticket.ticket_number.like(f'TKT-{date_str}-%')
|
Ticket.query
|
||||||
).order_by(Ticket.id.desc()).first()
|
.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:
|
if last:
|
||||||
seq = int(last.ticket_number.split('-')[-1]) + 1
|
seq = int(last.ticket_number.split('-')[-1]) + 1
|
||||||
else:
|
else:
|
||||||
|
|||||||
+219
-15
@@ -11,6 +11,7 @@ from app import db
|
|||||||
from app.models import (User, Ticket, Comment, ActivityLog, KnowledgeBase,
|
from app.models import (User, Ticket, Comment, ActivityLog, KnowledgeBase,
|
||||||
KBAttachment, UserRole, TicketStatus)
|
KBAttachment, UserRole, TicketStatus)
|
||||||
from app.services.log_service import log_action
|
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')
|
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -73,6 +74,13 @@ def it_required(f):
|
|||||||
@login_required
|
@login_required
|
||||||
@it_required
|
@it_required
|
||||||
def index():
|
def index():
|
||||||
|
from sqlalchemy import func, case
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
day7 = now - timedelta(days=7)
|
||||||
|
day30 = now - timedelta(days=30)
|
||||||
|
|
||||||
|
# ── Headline stats ────────────────────────────────────────────────────────
|
||||||
stats = {
|
stats = {
|
||||||
'total_tickets' : Ticket.query.count(),
|
'total_tickets' : Ticket.query.count(),
|
||||||
'open' : Ticket.query.filter_by(status=TicketStatus.OPEN).count(),
|
'open' : Ticket.query.filter_by(status=TicketStatus.OPEN).count(),
|
||||||
@@ -85,8 +93,177 @@ def index():
|
|||||||
User.role.in_([UserRole.IT_STAFF, UserRole.ADMIN]),
|
User.role.in_([UserRole.IT_STAFF, UserRole.ADMIN]),
|
||||||
User.is_active == True).count(),
|
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 ─────────────────────────────────────────────────────────
|
# ─── User Management ─────────────────────────────────────────────────────────
|
||||||
@@ -127,12 +304,9 @@ def create_user():
|
|||||||
flash('That username is already taken.', 'danger')
|
flash('That username is already taken.', 'danger')
|
||||||
return render_template('admin/create_user.html', roles=_roles(), form=request.form)
|
return render_template('admin/create_user.html', roles=_roles(), form=request.form)
|
||||||
|
|
||||||
if password != confirm:
|
pw_error = validate_password(password, confirm)
|
||||||
flash('Passwords do not match.', 'danger')
|
if pw_error:
|
||||||
return render_template('admin/create_user.html', roles=_roles(), form=request.form)
|
flash(pw_error, 'danger')
|
||||||
|
|
||||||
if len(password) < 8:
|
|
||||||
flash('Password must be at least 8 characters.', 'danger')
|
|
||||||
return render_template('admin/create_user.html', roles=_roles(), form=request.form)
|
return render_template('admin/create_user.html', roles=_roles(), form=request.form)
|
||||||
|
|
||||||
# ── Create ────────────────────────────────────────────────────────────
|
# ── Create ────────────────────────────────────────────────────────────
|
||||||
@@ -147,6 +321,10 @@ def create_user():
|
|||||||
)
|
)
|
||||||
user.set_password(password)
|
user.set_password(password)
|
||||||
db.session.add(user)
|
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,
|
log_action(current_user.id, 'admin_user_create', 'user', user.id,
|
||||||
f'email={email} role={role}')
|
f'email={email} role={role}')
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -172,6 +350,11 @@ def edit_user(user_id):
|
|||||||
user.is_active = bool(request.form.get('is_active'))
|
user.is_active = bool(request.form.get('is_active'))
|
||||||
new_pw = request.form.get('new_password', '')
|
new_pw = request.form.get('new_password', '')
|
||||||
if new_pw:
|
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)
|
user.set_password(new_pw)
|
||||||
logger.info(f'[ADMIN PASSWORD RESET] target_user_id={user.id} by admin_id={current_user.id}')
|
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,
|
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:
|
if not f or not f.filename:
|
||||||
return jsonify({'error': 'No file provided'}), 400
|
return jsonify({'error': 'No file provided'}), 400
|
||||||
|
|
||||||
ext = f.filename.rsplit('.', 1)[-1].lower() if '.' in f.filename else ''
|
_image_exts = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
|
||||||
if ext not in {'png', 'jpg', 'jpeg', 'gif', 'webp'}:
|
file_error = validate_file(f, _image_exts)
|
||||||
return jsonify({'error': 'Only image files are accepted (PNG, JPG, GIF, WEBP)'}), 400
|
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}"
|
stored_name = f"{uuid.uuid4().hex}.{ext}"
|
||||||
upload_dir = current_app.config['UPLOAD_FOLDER']
|
upload_dir = current_app.config['UPLOAD_FOLDER']
|
||||||
os.makedirs(upload_dir, exist_ok=True)
|
os.makedirs(upload_dir, exist_ok=True)
|
||||||
@@ -329,10 +515,20 @@ def kb_upload_image():
|
|||||||
|
|
||||||
# ── File-serve route (images embedded in articles + attachment downloads) ─────
|
# ── 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
|
@login_required
|
||||||
def kb_serve_file(stored_name):
|
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']
|
upload_dir = current_app.config['UPLOAD_FOLDER']
|
||||||
return send_from_directory(upload_dir, stored_name)
|
return send_from_directory(upload_dir, stored_name)
|
||||||
|
|
||||||
@@ -400,7 +596,11 @@ def kb_new():
|
|||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
for f in request.files.getlist('attachments'):
|
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)
|
att = _save_kb_file(f, article.id)
|
||||||
db.session.add(att)
|
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'))
|
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'):
|
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)
|
att = _save_kb_file(f, article.id)
|
||||||
db.session.add(att)
|
db.session.add(att)
|
||||||
|
|
||||||
|
|||||||
+12
-9
@@ -6,6 +6,7 @@ from flask_login import login_user, logout_user, login_required, current_user
|
|||||||
from app import db, limiter
|
from app import db, limiter
|
||||||
from app.models import User, UserRole
|
from app.models import User, UserRole
|
||||||
from app.services.log_service import log_action
|
from app.services.log_service import log_action
|
||||||
|
from app.services.validation_service import validate_password
|
||||||
|
|
||||||
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
|
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -75,10 +76,10 @@ def register():
|
|||||||
flash('Email already registered.', 'danger')
|
flash('Email already registered.', 'danger')
|
||||||
elif User.query.filter_by(username=username).first():
|
elif User.query.filter_by(username=username).first():
|
||||||
flash('Username already taken.', 'danger')
|
flash('Username already taken.', 'danger')
|
||||||
elif password != confirm:
|
else:
|
||||||
flash('Passwords do not match.', 'danger')
|
pw_error = validate_password(password, confirm)
|
||||||
elif len(password) < 8:
|
if pw_error:
|
||||||
flash('Password must be at least 8 characters.', 'danger')
|
flash(pw_error, 'danger')
|
||||||
else:
|
else:
|
||||||
user = User(
|
user = User(
|
||||||
email = email,
|
email = email,
|
||||||
@@ -90,6 +91,10 @@ def register():
|
|||||||
)
|
)
|
||||||
user.set_password(password)
|
user.set_password(password)
|
||||||
db.session.add(user)
|
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}')
|
log_action(user.id, 'user_register', 'user', user.id, f'email={email}')
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
logger.info(f'[AUTH REGISTER] user_id={user.id} email={email}')
|
logger.info(f'[AUTH REGISTER] user_id={user.id} email={email}')
|
||||||
@@ -128,11 +133,9 @@ def profile():
|
|||||||
current_user.web_notif = web_notif
|
current_user.web_notif = web_notif
|
||||||
|
|
||||||
if new_pw:
|
if new_pw:
|
||||||
if new_pw != confirm_pw:
|
pw_error = validate_password(new_pw, confirm_pw)
|
||||||
flash('Passwords do not match.', 'danger')
|
if pw_error:
|
||||||
return render_template('auth/profile.html')
|
flash(pw_error, 'danger')
|
||||||
if len(new_pw) < 8:
|
|
||||||
flash('Password must be at least 8 characters.', 'danger')
|
|
||||||
return render_template('auth/profile.html')
|
return render_template('auth/profile.html')
|
||||||
current_user.set_password(new_pw)
|
current_user.set_password(new_pw)
|
||||||
logger.info(f'[AUTH PASSWORD CHANGE] user_id={current_user.id}')
|
logger.info(f'[AUTH PASSWORD CHANGE] user_id={current_user.id}')
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import logging
|
|||||||
import requests
|
import requests
|
||||||
from flask import Blueprint, request, jsonify, current_app
|
from flask import Blueprint, request, jsonify, current_app
|
||||||
from flask_login import login_required, current_user
|
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.models import Ticket, TicketStatus, TicketPriority, TicketCategory
|
||||||
from app.services.notification_service import notify_new_ticket
|
from app.services.notification_service import notify_new_ticket
|
||||||
from app.services.log_service import log_action
|
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'])
|
@chatbot_bp.route('/message', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
|
@limiter.limit('20 per minute; 100 per hour')
|
||||||
def chat():
|
def chat():
|
||||||
data = request.get_json(force=True)
|
data = request.get_json(force=True)
|
||||||
history = data.get('history', []) # [{role, content}, ...]
|
history = data.get('history', []) # [{role, content}, ...]
|
||||||
@@ -146,6 +147,10 @@ def chat():
|
|||||||
)
|
)
|
||||||
ticket.ticket_number = ticket.generate_ticket_number()
|
ticket.ticket_number = ticket.generate_ticket_number()
|
||||||
db.session.add(ticket)
|
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,
|
log_action(current_user.id, 'ticket_create_chatbot', 'ticket', ticket.id,
|
||||||
f'ticket_number={ticket.ticket_number} ai_generated=True')
|
f'ticket_number={ticket.ticket_number} ai_generated=True')
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|||||||
+47
-9
@@ -15,17 +15,16 @@ from app.services.notification_service import (
|
|||||||
notify_comment_added, notify_assignment,
|
notify_comment_added, notify_assignment,
|
||||||
)
|
)
|
||||||
from app.services.log_service import log_action, log_ticket_history
|
from app.services.log_service import log_action, log_ticket_history
|
||||||
|
from app.services.validation_service import validate_file
|
||||||
|
|
||||||
tickets_bp = Blueprint('tickets', __name__)
|
tickets_bp = Blueprint('tickets', __name__)
|
||||||
logger = logging.getLogger(__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'}
|
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):
|
def save_attachment(file, ticket_id=None, comment_id=None, uploader_id=None):
|
||||||
filename = secure_filename(file.filename)
|
filename = secure_filename(file.filename)
|
||||||
ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
|
ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
|
||||||
@@ -114,7 +113,11 @@ def create_ticket():
|
|||||||
|
|
||||||
# Handle file uploads
|
# Handle file uploads
|
||||||
for f in request.files.getlist('attachments'):
|
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)
|
save_attachment(f, ticket_id=ticket.id, uploader_id=current_user.id)
|
||||||
|
|
||||||
log_action(current_user.id, 'ticket_create', 'ticket', ticket.id,
|
log_action(current_user.id, 'ticket_create', 'ticket', ticket.id,
|
||||||
@@ -193,7 +196,11 @@ def ticket_detail(ticket_id):
|
|||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
for f in request.files.getlist('attachments'):
|
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,
|
save_attachment(f, ticket_id=ticket.id,
|
||||||
comment_id=comment.id, uploader_id=current_user.id)
|
comment_id=comment.id, uploader_id=current_user.id)
|
||||||
|
|
||||||
@@ -266,7 +273,7 @@ def update_ticket(ticket_id):
|
|||||||
def _user_label(uid):
|
def _user_label(uid):
|
||||||
if uid is None:
|
if uid is None:
|
||||||
return 'Unassigned'
|
return 'Unassigned'
|
||||||
u = User.query.get(uid)
|
u = db.session.get(User, uid)
|
||||||
return u.full_name if u else f'User #{uid}'
|
return u.full_name if u else f'User #{uid}'
|
||||||
|
|
||||||
ticket.assigned_to_id = new_assigned
|
ticket.assigned_to_id = new_assigned
|
||||||
@@ -275,7 +282,7 @@ def update_ticket(ticket_id):
|
|||||||
_user_label(new_assigned),
|
_user_label(new_assigned),
|
||||||
current_user.id)
|
current_user.id)
|
||||||
changes.append(f'assigned_to: {old_assigned} → {new_assigned}')
|
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.internal_notes = internal_notes
|
||||||
ticket.resolution_notes = resolution
|
ticket.resolution_notes = resolution
|
||||||
@@ -291,6 +298,13 @@ def update_ticket(ticket_id):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
logger.info(f'[TICKET UPDATE] ticket_id={ticket.id} changes={changes} by user_id={current_user.id}')
|
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:
|
if new_status != old_status:
|
||||||
notify_status_change(ticket, old_status, current_user)
|
notify_status_change(ticket, old_status, current_user)
|
||||||
|
|
||||||
@@ -322,6 +336,18 @@ def delete_comment(comment_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def download_attachment(att_id):
|
def download_attachment(att_id):
|
||||||
att = Attachment.query.get_or_404(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']
|
upload_dir = current_app.config['UPLOAD_FOLDER']
|
||||||
return send_from_directory(upload_dir, att.stored_name, as_attachment=True,
|
return send_from_directory(upload_dir, att.stored_name, as_attachment=True,
|
||||||
download_name=att.filename)
|
download_name=att.filename)
|
||||||
@@ -385,8 +411,20 @@ def knowledge_base():
|
|||||||
@login_required
|
@login_required
|
||||||
def kb_article(article_id):
|
def kb_article(article_id):
|
||||||
article = KnowledgeBase.query.get_or_404(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()
|
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)
|
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
|
own business objects. This ensures the log entry is only persisted when
|
||||||
the parent operation succeeds — an independent commit here would leave
|
the parent operation succeeds — an independent commit here would leave
|
||||||
orphaned log entries for operations that were subsequently rolled back.
|
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()
|
ip = _get_real_ip()
|
||||||
|
entry = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
entry = ActivityLog(
|
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}'
|
f'user_id={user_id} ip={ip}'
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
db.session.rollback()
|
# Expunge only the failed log entry — do NOT roll back the full session,
|
||||||
logger.error(f'[ACTIVITY LOG ERROR] {exc}')
|
# 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):
|
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
|
Like log_action, this function does NOT commit — the caller is responsible
|
||||||
for committing the session after all field changes have been recorded.
|
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
|
from app.models import TicketHistory
|
||||||
|
entry = None
|
||||||
try:
|
try:
|
||||||
entry = TicketHistory(
|
entry = TicketHistory(
|
||||||
ticket_id = ticket.id,
|
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}'
|
f'"{old_value}" -> "{new_value}" by user_id={changed_by_id}'
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
db.session.rollback()
|
# Expunge only the failed history entry — do NOT roll back the full
|
||||||
logger.error(f'[TICKET HISTORY ERROR] {exc}')
|
# 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,
|
ticket_id = ticket.id,
|
||||||
link = f'/tickets/{ticket.id}#comment-{comment.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:
|
if user and user.email_notif and not is_internal:
|
||||||
html = render_template_string(_STATUS_UPDATE_EMAIL,
|
html = render_template_string(_STATUS_UPDATE_EMAIL,
|
||||||
ticket_number = ticket.ticket_number,
|
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 [[]]
|
||||||
+219
-26
@@ -3,15 +3,16 @@
|
|||||||
{% block page_title %}IT Operations Overview{% endblock %}
|
{% block page_title %}IT Operations Overview{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Stats grid -->
|
|
||||||
|
{# ── Headline stat cards ───────────────────────────────────────────────────── #}
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
{% for label, value, icon, color, bg in [
|
{% for label, value, icon, color, bg in [
|
||||||
('Total Tickets', stats.total_tickets, 'bi-collection', 'var(--text)', 'rgba(255,255,255,.05)'),
|
('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)'),
|
('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)'),
|
('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)'),
|
('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)'),
|
('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)'),
|
('Active Users', stats.total_users, 'bi-people', 'var(--accent2)','rgba(123,94,167,.1)'),
|
||||||
] %}
|
] %}
|
||||||
<div class="col-6 col-xl-2">
|
<div class="col-6 col-xl-2">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
@@ -25,44 +26,236 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Activity log -->
|
{# ── Row 1: Performance KPIs + Priority breakdown ─────────────────────────── #}
|
||||||
<div class="card">
|
<div class="row g-3 mb-4">
|
||||||
<div class="card-header d-flex align-items-center justify-content-between">
|
|
||||||
<span><i class="bi bi-activity me-2"></i>Recent Activity</span>
|
{# KPI cards #}
|
||||||
{% if current_user.is_admin %}
|
<div class="col-lg-8">
|
||||||
<a href="{{ url_for('admin.activity_logs') }}" style="font-size:12px;color:var(--accent3);">Full log →</a>
|
<div class="card h-100">
|
||||||
{% endif %}
|
<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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{# 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 %}
|
||||||
|
|
||||||
|
<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">
|
<div class="card-body p-0">
|
||||||
|
{% if staff_stats %}
|
||||||
<table class="table mb-0">
|
<table class="table mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Action</th>
|
<th>Staff Member</th>
|
||||||
<th>User</th>
|
<th style="text-align:center;">Open</th>
|
||||||
<th>Entity</th>
|
<th style="text-align:center;">Urgent</th>
|
||||||
<th>Details</th>
|
<th style="text-align:center;">Resolved (30d)</th>
|
||||||
<th>Time</th>
|
<th style="text-align:center;">Avg Time</th>
|
||||||
|
<th style="text-align:center;">Total Resolved</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for log in recent_logs %}
|
{% for s in staff_stats %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td style="font-size:13px;font-weight:500;">{{ s.name }}</td>
|
||||||
<span class="mono" style="font-size:11px;
|
<td style="text-align:center;">
|
||||||
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 %};">
|
<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 %};">
|
||||||
{{ log.action }}
|
{{ s.assigned_open }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style="font-size:13px;">
|
<td style="text-align:center;">
|
||||||
{% if log.user %}{{ log.user.full_name }}{% else %}<span style="color:var(--muted);">System</span>{% endif %}
|
{% 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>
|
||||||
<td style="font-size:12px;color:var(--muted);">{{ log.entity_type or '—' }} {% if log.entity_id %}#{{ log.entity_id }}{% endif %}</td>
|
<td style="text-align:center;font-size:13px;color:var(--success);font-weight:600;">{{ s.resolved_30d }}</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="text-align:center;font-size:12px;color:var(--muted);">
|
||||||
<td style="font-size:11px;color:var(--muted);font-family:'Space Mono',monospace;">{{ log.created_at.strftime('%b %d %H:%M') }}</td>
|
{% 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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="p-4 text-center" style="color:var(--muted);font-size:13px;">No IT staff found.</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user