Initial commit

This commit is contained in:
2026-03-25 11:37:46 -04:00
commit dfdbb54875
41 changed files with 5079 additions and 0 deletions
View File
+265
View File
@@ -0,0 +1,265 @@
import logging
from functools import wraps
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
from flask_login import login_required, current_user
from app import db
from app.models import (User, Ticket, Comment, ActivityLog, KnowledgeBase,
UserRole, TicketStatus)
from app.services.log_service import log_action
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
logger = logging.getLogger(__name__)
def admin_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not current_user.is_authenticated or not current_user.is_admin:
abort(403)
return f(*args, **kwargs)
return decorated
def it_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not current_user.is_authenticated or not current_user.is_it_staff:
abort(403)
return f(*args, **kwargs)
return decorated
# ─── Admin Dashboard ──────────────────────────────────────────────────────────
@admin_bp.route('/')
@login_required
@it_required
def index():
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(),
}
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)
# ─── User Management ─────────────────────────────────────────────────────────
@admin_bp.route('/users')
@login_required
@admin_required
def users():
all_users = User.query.order_by(User.created_at.desc()).all()
return render_template('admin/users.html', users=all_users)
@admin_bp.route('/users/new', methods=['GET', 'POST'])
@login_required
@admin_required
def create_user():
if request.method == 'POST':
email = request.form.get('email', '').strip().lower()
username = request.form.get('username', '').strip()
full_name = request.form.get('full_name', '').strip()
department = request.form.get('department', '').strip()
phone = request.form.get('phone', '').strip()
role = request.form.get('role', UserRole.EMPLOYEE)
password = request.form.get('password', '')
confirm = request.form.get('confirm_password', '')
is_active = bool(request.form.get('is_active'))
# ── Validation ────────────────────────────────────────────────────────
if not email or not username or not full_name or not password:
flash('Email, username, full name and password are all required.', 'danger')
return render_template('admin/create_user.html', roles=_roles(), form=request.form)
if User.query.filter_by(email=email).first():
flash('That email address is already registered.', 'danger')
return render_template('admin/create_user.html', roles=_roles(), form=request.form)
if User.query.filter_by(username=username).first():
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')
return render_template('admin/create_user.html', roles=_roles(), form=request.form)
# ── Create ────────────────────────────────────────────────────────────
user = User(
email = email,
username = username,
full_name = full_name,
department = department,
phone = phone,
role = role,
is_active = is_active,
)
user.set_password(password)
db.session.add(user)
db.session.commit()
log_action(current_user.id, 'admin_user_create', 'user', user.id,
f'email={email} role={role}')
logger.info(f'[ADMIN USER CREATE] user_id={user.id} email={email} role={role} by admin_id={current_user.id}')
flash(f'User {full_name} ({email}) created successfully.', 'success')
return redirect(url_for('admin.users'))
return render_template('admin/create_user.html', roles=_roles(), form={})
@admin_bp.route('/users/<int:user_id>/edit', methods=['GET', 'POST'])
@login_required
@admin_required
def edit_user(user_id):
user = User.query.get_or_404(user_id)
if request.method == 'POST':
old_role = user.role
user.full_name = request.form.get('full_name', user.full_name).strip()
user.department= request.form.get('department', user.department).strip()
user.phone = request.form.get('phone', user.phone or '').strip()
user.role = request.form.get('role', user.role)
user.is_active = bool(request.form.get('is_active'))
new_pw = request.form.get('new_password', '')
if new_pw:
user.set_password(new_pw)
logger.info(f'[ADMIN PASSWORD RESET] target_user_id={user.id} by admin_id={current_user.id}')
db.session.commit()
log_action(current_user.id, 'admin_user_edit', 'user', user.id,
f'role_change={old_role}->{user.role} active={user.is_active}')
logger.info(f'[ADMIN USER EDIT] user_id={user.id} by admin_id={current_user.id}')
flash('User updated.', 'success')
return redirect(url_for('admin.users'))
return render_template('admin/edit_user.html', user=user, roles=_roles())
@admin_bp.route('/users/<int:user_id>/delete', methods=['POST'])
@login_required
@admin_required
def delete_user(user_id):
user = User.query.get_or_404(user_id)
if user.id == current_user.id:
flash('You cannot delete your own account.', 'danger')
return redirect(url_for('admin.users'))
user.is_active = False
db.session.commit()
log_action(current_user.id, 'admin_user_deactivate', 'user', user.id)
logger.info(f'[ADMIN USER DEACTIVATE] user_id={user.id} by admin_id={current_user.id}')
flash('User deactivated.', 'success')
return redirect(url_for('admin.users'))
# ─── Ticket Management (IT) ───────────────────────────────────────────────────
@admin_bp.route('/tickets')
@login_required
@it_required
def all_tickets():
page = request.args.get('page', 1, type=int)
status = request.args.get('status', '')
priority = request.args.get('priority', '')
assigned = request.args.get('assigned', '')
q = Ticket.query
if status: q = q.filter_by(status=status)
if priority: q = q.filter_by(priority=priority)
if assigned == 'me': q = q.filter_by(assigned_to_id=current_user.id)
elif assigned == 'unassigned': q = q.filter_by(assigned_to_id=None)
tickets = q.order_by(Ticket.created_at.desc()).paginate(page=page, per_page=25)
return render_template('admin/tickets.html', tickets=tickets,
status=status, priority=priority, assigned=assigned)
# ─── Knowledge Base Management ────────────────────────────────────────────────
@admin_bp.route('/kb')
@login_required
@it_required
def kb_list():
articles = KnowledgeBase.query.order_by(KnowledgeBase.created_at.desc()).all()
return render_template('admin/kb_list.html', articles=articles)
@admin_bp.route('/kb/new', methods=['GET', 'POST'])
@login_required
@it_required
def kb_new():
if request.method == 'POST':
article = KnowledgeBase(
title = request.form.get('title', '').strip(),
body = request.form.get('body', '').strip(),
category = request.form.get('category', ''),
tags = request.form.get('tags', ''),
author_id = current_user.id,
is_published= bool(request.form.get('is_published')),
)
db.session.add(article)
db.session.commit()
log_action(current_user.id, 'kb_create', 'knowledge_base', article.id,
f'title={article.title}')
logger.info(f'[KB CREATE] article_id={article.id} by user_id={current_user.id}')
flash('Article created.', 'success')
return redirect(url_for('admin.kb_list'))
return render_template('admin/kb_edit.html', article=None)
@admin_bp.route('/kb/<int:article_id>/edit', methods=['GET', 'POST'])
@login_required
@it_required
def kb_edit(article_id):
article = KnowledgeBase.query.get_or_404(article_id)
if request.method == 'POST':
article.title = request.form.get('title', article.title).strip()
article.body = request.form.get('body', article.body).strip()
article.category = request.form.get('category', article.category)
article.tags = request.form.get('tags', article.tags)
article.is_published= bool(request.form.get('is_published'))
db.session.commit()
log_action(current_user.id, 'kb_edit', 'knowledge_base', article.id)
logger.info(f'[KB EDIT] article_id={article.id} by user_id={current_user.id}')
flash('Article updated.', 'success')
return redirect(url_for('admin.kb_list'))
return render_template('admin/kb_edit.html', article=article)
@admin_bp.route('/kb/<int:article_id>/delete', methods=['POST'])
@login_required
@it_required
def kb_delete(article_id):
article = KnowledgeBase.query.get_or_404(article_id)
log_action(current_user.id, 'kb_delete', 'knowledge_base', article.id,
f'title={article.title}')
logger.info(f'[KB DELETE] article_id={article.id} by user_id={current_user.id}')
db.session.delete(article)
db.session.commit()
flash('Article deleted.', 'success')
return redirect(url_for('admin.kb_list'))
# ─── Activity Log ─────────────────────────────────────────────────────────────
@admin_bp.route('/logs')
@login_required
@admin_required
def activity_logs():
page = request.args.get('page', 1, type=int)
logs = ActivityLog.query.order_by(ActivityLog.created_at.desc()).paginate(
page=page, per_page=50)
return render_template('admin/activity_logs.html', logs=logs)
def _roles():
return [UserRole.EMPLOYEE, UserRole.IT_STAFF, UserRole.ADMIN]
+83
View File
@@ -0,0 +1,83 @@
import logging
from flask import Blueprint, jsonify, request
from flask_login import login_required, current_user
from flask_socketio import emit, join_room, leave_room
from app import db, socketio
from app.models import Notification, Ticket, TicketStatus, TicketPriority
from app.services.log_service import log_action
api_bp = Blueprint('api', __name__, url_prefix='/api')
logger = logging.getLogger(__name__)
# ─── Notifications API ────────────────────────────────────────────────────────
@api_bp.route('/notifications/unread-count')
@login_required
def unread_count():
count = Notification.query.filter_by(user_id=current_user.id, is_read=False).count()
return jsonify({'count': count})
@api_bp.route('/notifications/<int:notif_id>/read', methods=['POST'])
@login_required
def mark_read(notif_id):
notif = Notification.query.filter_by(id=notif_id, user_id=current_user.id).first_or_404()
notif.is_read = True
db.session.commit()
return jsonify({'ok': True})
@api_bp.route('/notifications/mark-all-read', methods=['POST'])
@login_required
def mark_all_read():
Notification.query.filter_by(user_id=current_user.id, is_read=False).update({'is_read': True})
db.session.commit()
return jsonify({'ok': True})
# ─── Ticket Stats API (IT) ────────────────────────────────────────────────────
@api_bp.route('/stats/tickets')
@login_required
def ticket_stats():
if not current_user.is_it_staff:
return jsonify({'error': 'Forbidden'}), 403
stats = {
'open' : Ticket.query.filter_by(status=TicketStatus.OPEN).count(),
'in_progress' : Ticket.query.filter_by(status=TicketStatus.IN_PROGRESS).count(),
'pending' : Ticket.query.filter_by(status=TicketStatus.PENDING).count(),
'resolved' : Ticket.query.filter_by(status=TicketStatus.RESOLVED).count(),
'closed' : Ticket.query.filter_by(status=TicketStatus.CLOSED).count(),
}
return jsonify(stats)
# ─── WebSocket Events ─────────────────────────────────────────────────────────
@socketio.on('connect')
def on_connect():
if current_user.is_authenticated:
join_room(f'user_{current_user.id}')
logger.info(f'[SOCKET CONNECT] user_id={current_user.id}')
@socketio.on('disconnect')
def on_disconnect():
if current_user.is_authenticated:
leave_room(f'user_{current_user.id}')
logger.info(f'[SOCKET DISCONNECT] user_id={current_user.id}')
@socketio.on('join_ticket')
def on_join_ticket(data):
if current_user.is_authenticated:
ticket_id = data.get('ticket_id')
join_room(f'ticket_{ticket_id}')
@socketio.on('leave_ticket')
def on_leave_ticket(data):
if current_user.is_authenticated:
ticket_id = data.get('ticket_id')
leave_room(f'ticket_{ticket_id}')
+124
View File
@@ -0,0 +1,124 @@
import logging
from datetime import datetime
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, login_required, current_user
from app import db
from app.models import User, UserRole
from app.services.log_service import log_action
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
logger = logging.getLogger(__name__)
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('tickets.dashboard'))
if request.method == 'POST':
email = request.form.get('email', '').strip().lower()
password = request.form.get('password', '')
remember = bool(request.form.get('remember'))
user = User.query.filter_by(email=email).first()
if user and user.check_password(password) and user.is_active:
login_user(user, remember=remember)
user.last_login = datetime.utcnow()
db.session.commit()
log_action(user.id, 'user_login', 'user', user.id, f'email={email}')
logger.info(f'[AUTH LOGIN] user_id={user.id} email={email}')
next_page = request.args.get('next')
return redirect(next_page or url_for('tickets.dashboard'))
else:
logger.warning(f'[AUTH FAILED] email={email} ip={request.remote_addr}')
flash('Invalid credentials or account disabled.', 'danger')
return render_template('auth/login.html')
@auth_bp.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('tickets.dashboard'))
if request.method == 'POST':
email = request.form.get('email', '').strip().lower()
username = request.form.get('username', '').strip()
full_name = request.form.get('full_name', '').strip()
department = request.form.get('department', '').strip()
phone = request.form.get('phone', '').strip()
password = request.form.get('password', '')
confirm = request.form.get('confirm_password', '')
if User.query.filter_by(email=email).first():
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)
db.session.commit()
log_action(user.id, 'user_register', 'user', user.id, f'email={email}')
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')
@auth_bp.route('/logout')
@login_required
def logout():
log_action(current_user.id, 'user_logout', 'user', current_user.id)
logger.info(f'[AUTH LOGOUT] user_id={current_user.id}')
logout_user()
flash('You have been logged out.', 'info')
return redirect(url_for('auth.login'))
@auth_bp.route('/profile', methods=['GET', 'POST'])
@login_required
def profile():
if request.method == 'POST':
full_name = request.form.get('full_name', '').strip()
department = request.form.get('department', '').strip()
phone = request.form.get('phone', '').strip()
email_notif= bool(request.form.get('email_notif'))
web_notif = bool(request.form.get('web_notif'))
new_pw = request.form.get('new_password', '')
confirm_pw = request.form.get('confirm_password', '')
current_user.full_name = full_name
current_user.department = department
current_user.phone = phone
current_user.email_notif= email_notif
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')
return render_template('auth/profile.html')
current_user.set_password(new_pw)
logger.info(f'[AUTH PASSWORD CHANGE] user_id={current_user.id}')
db.session.commit()
log_action(current_user.id, 'user_profile_update', 'user', current_user.id)
logger.info(f'[AUTH PROFILE UPDATE] user_id={current_user.id}')
flash('Profile updated successfully.', 'success')
return render_template('auth/profile.html')
+114
View File
@@ -0,0 +1,114 @@
import json
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.models import Ticket, TicketStatus, TicketPriority, TicketCategory
from app.services.notification_service import notify_new_ticket
from app.services.log_service import log_action
chatbot_bp = Blueprint('chatbot', __name__, url_prefix='/chatbot')
logger = logging.getLogger(__name__)
_SYSTEM_PROMPT = """You are an IT Helpdesk Assistant for an internal IT ticket system.
Your job is to:
1. Help employees report IT issues conversationally.
2. Gather all required information to create a support ticket:
- Issue title (short summary)
- Detailed description
- Category (hardware, software, network, access, email, printer, phone, security, other)
- Priority (low, medium, high, critical)
- Location (optional)
- Asset tag (optional device serial / asset number)
3. When you have enough information, respond with a JSON block like this (and ONLY this, no extra text):
{"action": "create_ticket", "title": "...", "description": "...", "category": "...", "priority": "...", "location": "...", "asset_tag": "..."}
4. For general IT questions, answer helpfully but briefly.
5. If the user seems frustrated or has a critical outage, set priority to "critical".
6. Keep your tone professional, friendly, and concise.
7. Always ask clarifying questions if you need more detail before creating a ticket.
"""
@chatbot_bp.route('/message', methods=['POST'])
@login_required
def chat():
data = request.get_json(force=True)
history = data.get('history', []) # [{role, content}, ...]
user_msg = data.get('message', '').strip()
if not user_msg:
return jsonify({'error': 'Empty message'}), 400
api_key = current_app.config.get('ANTHROPIC_API_KEY', '')
if not api_key:
return jsonify({'reply': "The AI assistant is not configured yet. Please contact your IT administrator.", 'ticket': None})
messages = history + [{'role': 'user', 'content': user_msg}]
try:
resp = requests.post(
'https://api.anthropic.com/v1/messages',
headers={
'x-api-key' : api_key,
'anthropic-version': '2023-06-01',
'content-type' : 'application/json',
},
json={
'model' : 'claude-sonnet-4-20250514',
'max_tokens': 1024,
'system' : _SYSTEM_PROMPT,
'messages' : messages,
},
timeout=30,
)
resp.raise_for_status()
reply_text = resp.json()['content'][0]['text'].strip()
except Exception as exc:
logger.error(f'[CHATBOT API ERROR] {exc}')
return jsonify({'reply': 'Sorry, I encountered an error. Please try again or submit a ticket manually.', 'ticket': None})
# Check if the AI wants to create a ticket
ticket_data = None
if '"action": "create_ticket"' in reply_text or "'action': 'create_ticket'" in reply_text:
try:
start = reply_text.find('{')
end = reply_text.rfind('}') + 1
parsed = json.loads(reply_text[start:end])
if parsed.get('action') == 'create_ticket':
ticket = Ticket(
title = parsed.get('title', 'Untitled Issue'),
description = parsed.get('description', ''),
category = parsed.get('category', TicketCategory.OTHER),
priority = parsed.get('priority', TicketPriority.MEDIUM),
location = parsed.get('location', ''),
asset_tag = parsed.get('asset_tag', ''),
created_by_id = current_user.id,
status = TicketStatus.OPEN,
ai_generated = True,
)
ticket.ticket_number = ticket.generate_ticket_number()
db.session.add(ticket)
db.session.commit()
log_action(current_user.id, 'ticket_create_chatbot', 'ticket', ticket.id,
f'ticket_number={ticket.ticket_number} ai_generated=True')
logger.info(f'[CHATBOT TICKET CREATE] ticket_id={ticket.id} number={ticket.ticket_number} user_id={current_user.id}')
notify_new_ticket(ticket)
ticket_data = {
'id' : ticket.id,
'ticket_number' : ticket.ticket_number,
'title' : ticket.title,
'url' : f'/tickets/{ticket.id}',
}
reply_text = (
f"✅ **Ticket Created!**\n\n"
f"I've submitted your ticket **{ticket.ticket_number}**: _{ticket.title}_\n\n"
f"Our IT team has been notified and will get back to you shortly. "
f"You can track your ticket [here](/tickets/{ticket.id})."
)
except (json.JSONDecodeError, KeyError) as exc:
logger.warning(f'[CHATBOT PARSE ERROR] Could not parse ticket JSON: {exc}')
return jsonify({'reply': reply_text, 'ticket': ticket_data})
+370
View File
@@ -0,0 +1,370 @@
import os
import logging
import uuid
from datetime import datetime
from flask import (Blueprint, render_template, redirect, url_for,
flash, request, current_app, send_from_directory, abort)
from flask_login import login_required, current_user
from werkzeug.utils import secure_filename
from app import db
from app.models import (Ticket, Comment, Attachment, Notification,
TicketStatus, TicketPriority, TicketCategory,
User, UserRole, KnowledgeBase)
from app.services.notification_service import (
notify_new_ticket, notify_status_change,
notify_comment_added, notify_assignment,
)
from app.services.log_service import log_action, log_ticket_history
tickets_bp = Blueprint('tickets', __name__)
logger = logging.getLogger(__name__)
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 ''
stored_name = f"{uuid.uuid4().hex}.{ext}"
upload_dir = current_app.config['UPLOAD_FOLDER']
file.save(os.path.join(upload_dir, stored_name))
att = Attachment(
ticket_id = ticket_id,
comment_id = comment_id,
filename = filename,
stored_name= stored_name,
file_size = os.path.getsize(os.path.join(upload_dir, stored_name)),
mime_type = file.content_type,
uploaded_by= uploader_id,
)
db.session.add(att)
return att
# ─── Dashboard ────────────────────────────────────────────────────────────────
@tickets_bp.route('/')
@tickets_bp.route('/dashboard')
@login_required
def dashboard():
if current_user.is_it_staff:
open_count = Ticket.query.filter_by(status=TicketStatus.OPEN).count()
in_progress_count= Ticket.query.filter_by(status=TicketStatus.IN_PROGRESS).count()
pending_count = Ticket.query.filter_by(status=TicketStatus.PENDING).count()
resolved_count = Ticket.query.filter_by(status=TicketStatus.RESOLVED).count()
my_tickets = Ticket.query.filter_by(assigned_to_id=current_user.id).filter(
Ticket.status.notin_([TicketStatus.CLOSED])
).order_by(Ticket.created_at.desc()).limit(10).all()
recent_tickets = Ticket.query.order_by(Ticket.created_at.desc()).limit(15).all()
return render_template('tickets/dashboard_it.html',
open_count=open_count, in_progress_count=in_progress_count,
pending_count=pending_count, resolved_count=resolved_count,
my_tickets=my_tickets, recent_tickets=recent_tickets,
)
else:
my_tickets = Ticket.query.filter_by(created_by_id=current_user.id).order_by(
Ticket.created_at.desc()).limit(20).all()
open_count = sum(1 for t in my_tickets if t.status == TicketStatus.OPEN)
active_count = sum(1 for t in my_tickets if t.status == TicketStatus.IN_PROGRESS)
resolved_count = sum(1 for t in my_tickets if t.status == TicketStatus.RESOLVED)
articles = KnowledgeBase.query.filter_by(is_published=True).order_by(
KnowledgeBase.view_count.desc()).limit(5).all()
return render_template('tickets/dashboard_employee.html',
my_tickets=my_tickets, open_count=open_count,
active_count=active_count, resolved_count=resolved_count,
articles=articles,
)
# ─── Create Ticket ────────────────────────────────────────────────────────────
@tickets_bp.route('/tickets/new', methods=['GET', 'POST'])
@login_required
def create_ticket():
if request.method == 'POST':
title = request.form.get('title', '').strip()
description = request.form.get('description', '').strip()
category = request.form.get('category', TicketCategory.OTHER)
priority = request.form.get('priority', TicketPriority.MEDIUM)
location = request.form.get('location', '').strip()
asset_tag = request.form.get('asset_tag', '').strip()
if not title or not description:
flash('Title and description are required.', 'danger')
return render_template('tickets/create.html',
categories=_categories(), priorities=_priorities())
ticket = Ticket(
title = title,
description = description,
category = category,
priority = priority,
location = location,
asset_tag = asset_tag,
created_by_id = current_user.id,
status = TicketStatus.OPEN,
)
ticket.ticket_number = ticket.generate_ticket_number()
db.session.add(ticket)
db.session.flush() # get ticket.id before attachments
# Handle file uploads
for f in request.files.getlist('attachments'):
if f and f.filename and allowed_file(f.filename):
save_attachment(f, ticket_id=ticket.id, uploader_id=current_user.id)
db.session.commit()
log_action(current_user.id, 'ticket_create', 'ticket', ticket.id,
f'ticket_number={ticket.ticket_number} priority={priority} category={category}')
logger.info(f'[TICKET CREATE] ticket_id={ticket.id} number={ticket.ticket_number} by user_id={current_user.id}')
notify_new_ticket(ticket)
flash(f'Ticket {ticket.ticket_number} created successfully!', 'success')
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket.id))
return render_template('tickets/create.html',
categories=_categories(), priorities=_priorities())
# ─── Ticket List ──────────────────────────────────────────────────────────────
@tickets_bp.route('/tickets')
@login_required
def ticket_list():
page = request.args.get('page', 1, type=int)
status = request.args.get('status', '')
priority = request.args.get('priority', '')
category = request.args.get('category', '')
search = request.args.get('q', '')
query = Ticket.query
if not current_user.is_it_staff:
query = query.filter_by(created_by_id=current_user.id)
if status:
query = query.filter_by(status=status)
if priority:
query = query.filter_by(priority=priority)
if category:
query = query.filter_by(category=category)
if search:
query = query.filter(
Ticket.title.ilike(f'%{search}%') |
Ticket.ticket_number.ilike(f'%{search}%') |
Ticket.description.ilike(f'%{search}%')
)
tickets = query.order_by(Ticket.created_at.desc()).paginate(page=page, per_page=20)
return render_template('tickets/list.html',
tickets=tickets, status=status, priority=priority,
category=category, search=search,
statuses=_statuses(), priorities=_priorities(), categories=_categories(),
)
# ─── Ticket Detail ────────────────────────────────────────────────────────────
@tickets_bp.route('/tickets/<int:ticket_id>', methods=['GET', 'POST'])
@login_required
def ticket_detail(ticket_id):
ticket = Ticket.query.get_or_404(ticket_id)
# Employees can only view their own tickets
if not current_user.is_it_staff and ticket.created_by_id != current_user.id:
abort(403)
if request.method == 'POST':
body = request.form.get('body', '').strip()
is_internal = bool(request.form.get('is_internal')) and current_user.is_it_staff
if not body:
flash('Comment cannot be empty.', 'danger')
else:
comment = Comment(
ticket_id = ticket.id,
author_id = current_user.id,
body = body,
is_internal= is_internal,
)
db.session.add(comment)
db.session.flush()
for f in request.files.getlist('attachments'):
if f and f.filename and allowed_file(f.filename):
save_attachment(f, ticket_id=ticket.id,
comment_id=comment.id, uploader_id=current_user.id)
db.session.commit()
log_action(current_user.id, 'comment_create', 'comment', comment.id,
f'ticket_id={ticket.id} internal={is_internal}')
logger.info(f'[COMMENT CREATE] comment_id={comment.id} ticket_id={ticket.id} by user_id={current_user.id}')
notify_comment_added(comment)
flash('Comment added.', 'success')
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket.id))
comments = Comment.query.filter_by(ticket_id=ticket.id)
if not current_user.is_it_staff:
comments = comments.filter_by(is_internal=False)
comments = comments.order_by(Comment.created_at.asc()).all()
it_staff = User.query.filter(
User.role.in_([UserRole.IT_STAFF, UserRole.ADMIN]),
User.is_active == True,
).all() if current_user.is_it_staff else []
history = ticket.history.order_by('changed_at').all()
return render_template('tickets/detail.html',
ticket=ticket, comments=comments,
it_staff=it_staff, history=history,
statuses=_statuses(), priorities=_priorities(),
)
# ─── Update Ticket (IT Only) ──────────────────────────────────────────────────
@tickets_bp.route('/tickets/<int:ticket_id>/update', methods=['POST'])
@login_required
def update_ticket(ticket_id):
if not current_user.is_it_staff:
abort(403)
ticket = Ticket.query.get_or_404(ticket_id)
old_status = ticket.status
old_priority = ticket.priority
old_assigned = ticket.assigned_to_id
new_status = request.form.get('status', ticket.status)
new_priority = request.form.get('priority', ticket.priority)
new_assigned = request.form.get('assigned_to_id', type=int)
internal_notes= request.form.get('internal_notes', ticket.internal_notes)
resolution = request.form.get('resolution_notes', ticket.resolution_notes)
due_date_str = request.form.get('due_date', '')
changes = []
if new_status != old_status:
ticket.status = new_status
log_ticket_history(ticket, 'status', old_status, new_status, current_user.id)
changes.append(f'status: {old_status}{new_status}')
if new_status == TicketStatus.RESOLVED:
ticket.resolved_at = datetime.utcnow()
elif new_status == TicketStatus.CLOSED:
ticket.closed_at = datetime.utcnow()
if new_priority != old_priority:
ticket.priority = new_priority
log_ticket_history(ticket, 'priority', old_priority, new_priority, current_user.id)
changes.append(f'priority: {old_priority}{new_priority}')
if new_assigned != old_assigned:
ticket.assigned_to_id = new_assigned
log_ticket_history(ticket, 'assigned_to', str(old_assigned), str(new_assigned), current_user.id)
changes.append(f'assigned_to: {old_assigned}{new_assigned}')
notify_assignment(ticket, current_user)
ticket.internal_notes = internal_notes
ticket.resolution_notes = resolution
if due_date_str:
try:
ticket.due_date = datetime.strptime(due_date_str, '%Y-%m-%d')
except ValueError:
pass
db.session.commit()
log_action(current_user.id, 'ticket_update', 'ticket', ticket.id,
f'changes=[{"; ".join(changes)}]')
logger.info(f'[TICKET UPDATE] ticket_id={ticket.id} changes={changes} by user_id={current_user.id}')
if new_status != old_status:
notify_status_change(ticket, old_status, current_user)
flash('Ticket updated successfully.', 'success')
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket.id))
# ─── Delete Comment (IT Only) ─────────────────────────────────────────────────
@tickets_bp.route('/comments/<int:comment_id>/delete', methods=['POST'])
@login_required
def delete_comment(comment_id):
comment = Comment.query.get_or_404(comment_id)
if not current_user.is_it_staff and comment.author_id != current_user.id:
abort(403)
ticket_id = comment.ticket_id
log_action(current_user.id, 'comment_delete', 'comment', comment.id,
f'ticket_id={ticket_id}')
logger.info(f'[COMMENT DELETE] comment_id={comment.id} ticket_id={ticket_id} by user_id={current_user.id}')
db.session.delete(comment)
db.session.commit()
flash('Comment deleted.', 'success')
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id))
# ─── Attachment Download ──────────────────────────────────────────────────────
@tickets_bp.route('/attachments/<int:att_id>')
@login_required
def download_attachment(att_id):
att = Attachment.query.get_or_404(att_id)
upload_dir = current_app.config['UPLOAD_FOLDER']
return send_from_directory(upload_dir, att.stored_name, as_attachment=True,
download_name=att.filename)
# ─── Notifications ────────────────────────────────────────────────────────────
@tickets_bp.route('/notifications')
@login_required
def notifications():
notifs = Notification.query.filter_by(user_id=current_user.id).order_by(
Notification.created_at.desc()).paginate(page=request.args.get('page', 1, type=int), per_page=30)
return render_template('tickets/notifications.html', notifs=notifs)
@tickets_bp.route('/notifications/mark-read', methods=['POST'])
@login_required
def mark_notifications_read():
Notification.query.filter_by(user_id=current_user.id, is_read=False).update({'is_read': True})
db.session.commit()
return redirect(request.referrer or url_for('tickets.notifications'))
# ─── Knowledge Base ───────────────────────────────────────────────────────────
@tickets_bp.route('/kb')
@login_required
def knowledge_base():
articles = KnowledgeBase.query.filter_by(is_published=True).order_by(
KnowledgeBase.view_count.desc()).all()
return render_template('tickets/knowledge_base.html', articles=articles)
@tickets_bp.route('/kb/<int:article_id>')
@login_required
def kb_article(article_id):
article = KnowledgeBase.query.get_or_404(article_id)
article.view_count += 1
db.session.commit()
return render_template('tickets/kb_article.html', article=article)
# ─── Helpers ─────────────────────────────────────────────────────────────────
def _statuses():
return [TicketStatus.OPEN, TicketStatus.IN_PROGRESS,
TicketStatus.PENDING, TicketStatus.RESOLVED, TicketStatus.CLOSED]
def _priorities():
return [TicketPriority.LOW, TicketPriority.MEDIUM,
TicketPriority.HIGH, TicketPriority.CRITICAL]
def _categories():
return [TicketCategory.HARDWARE, TicketCategory.SOFTWARE,
TicketCategory.NETWORK, TicketCategory.ACCESS,
TicketCategory.EMAIL, TicketCategory.PRINTER,
TicketCategory.PHONE, TicketCategory.SECURITY, TicketCategory.OTHER]