Enhance Ticket management functionalities

This commit is contained in:
2026-03-25 17:33:34 -04:00
parent bee7146073
commit 49542d1f14
7 changed files with 218 additions and 69 deletions
+15 -1
View File
@@ -32,7 +32,21 @@ def create_app(config_name=None):
login_manager.init_app(app) login_manager.init_app(app)
mail.init_app(app) mail.init_app(app)
migrate.init_app(app, db) migrate.init_app(app, db)
socketio.init_app(app, async_mode='eventlet', cors_allowed_origins='*') socketio.init_app(
app,
async_mode = 'eventlet',
cors_allowed_origins = '*',
# Ping settings: server sends a ping every 25s, client has 60s to respond.
# This ensures dead connections are detected and closed cleanly rather than
# being torn down by nginx timeouts, which causes [Errno 9] Bad file descriptor.
ping_interval = 25,
ping_timeout = 60,
# Suppress eventlet's low-level socket error messages from flooding the log.
# The errors still occur occasionally (it's an eventlet limitation) but are
# benign — connections recover automatically via Socket.IO's reconnect logic.
logger = False,
engineio_logger = False,
)
login_manager.login_view = 'auth.login' login_manager.login_view = 'auth.login'
login_manager.login_message = 'Please log in to access this page.' login_manager.login_message = 'Please log in to access this page.'
+23
View File
@@ -12,6 +12,29 @@ logger = logging.getLogger(__name__)
# ─── Notifications API ──────────────────────────────────────────────────────── # ─── Notifications API ────────────────────────────────────────────────────────
@api_bp.route('/notifications')
@login_required
def get_notifications():
"""Return the 20 most recent notifications for the current user as JSON."""
notifs = (Notification.query
.filter_by(user_id=current_user.id)
.order_by(Notification.created_at.desc())
.limit(20)
.all())
return jsonify({'notifications': [
{
'id' : n.id,
'type' : n.type,
'title' : n.title,
'message' : n.message or '',
'link' : n.link or '',
'is_read' : n.is_read,
'created_at': n.created_at.strftime('%b %d, %H:%M'),
}
for n in notifs
]})
@api_bp.route('/notifications/unread-count') @api_bp.route('/notifications/unread-count')
@login_required @login_required
def unread_count(): def unread_count():
+32 -4
View File
@@ -77,15 +77,26 @@ def create_notification(user_id, notif_type, title, message, ticket_id=None, lin
db.session.commit() db.session.commit()
logger.info(f'[NOTIFICATION CREATE] user_id={user_id} type={notif_type} ticket_id={ticket_id}') logger.info(f'[NOTIFICATION CREATE] user_id={user_id} type={notif_type} ticket_id={ticket_id}')
# Real-time push # Real-time push — schedule via socketio.start_background_task so the
socketio.emit('new_notification', { # emit runs inside eventlet's green-thread pool, not inline in the WSGI
# request context. Inline emits during a polling→WebSocket upgrade can
# race with the upgrade handshake and disconnect the client.
payload = {
'id' : notif.id, 'id' : notif.id,
'type' : notif_type, 'type' : notif_type,
'title' : title, 'title' : title,
'message' : message, 'message' : message,
'link' : link, 'link' : link,
'created_at': notif.created_at.isoformat(), 'created_at': notif.created_at.isoformat(),
}, room=f'user_{user_id}') }
def _emit():
socketio.emit(
'new_notification',
payload,
to=f'user_{user_id}', # 'to' is the modern alias for 'room'
namespace='/', # explicit default namespace — avoids
) # ambiguity under reverse-proxy setups
socketio.start_background_task(_emit)
except Exception as exc: except Exception as exc:
db.session.rollback() db.session.rollback()
@@ -95,7 +106,21 @@ def create_notification(user_id, notif_type, title, message, ticket_id=None, lin
# ─── Email Notification ─────────────────────────────────────────────────────── # ─── Email Notification ───────────────────────────────────────────────────────
def send_email(subject, recipients, html_body): def send_email(subject, recipients, html_body):
"""Send an email; silently log errors so it never blocks the main flow.""" """
Send an email in a background thread so it never blocks the request.
Flask-Mail opens an SMTP connection synchronously. Running it in the main
request thread means a slow or failing SMTP server delays the entire
response — including the WebSocket notification push that follows.
Using a daemon thread isolates SMTP failures from the request lifecycle.
"""
from threading import Thread
from flask import current_app
app = current_app._get_current_object() # real app, not the proxy
def _send():
with app.app_context():
try: try:
msg = Message(subject=subject, recipients=recipients, html=html_body) msg = Message(subject=subject, recipients=recipients, html=html_body)
mail.send(msg) mail.send(msg)
@@ -103,6 +128,9 @@ def send_email(subject, recipients, html_body):
except Exception as exc: except Exception as exc:
logger.error(f'[EMAIL ERROR] Failed to send "{subject}" to {recipients}: {exc}') logger.error(f'[EMAIL ERROR] Failed to send "{subject}" to {recipients}: {exc}')
t = Thread(target=_send, daemon=True)
t.start()
# ─── Ticket Event Helpers ───────────────────────────────────────────────────── # ─── Ticket Event Helpers ─────────────────────────────────────────────────────
+80 -20
View File
@@ -445,8 +445,12 @@ const socket = io({
transports: ['polling', 'websocket'], // polling first → upgrade to WS after handshake transports: ['polling', 'websocket'], // polling first → upgrade to WS after handshake
upgrade: true, upgrade: true,
reconnection: true, reconnection: true,
reconnectionAttempts: 5, reconnectionAttempts: 10,
reconnectionDelay: 2000, reconnectionDelay: 3000, // wait 3s before first reconnect attempt
reconnectionDelayMax: 15000, // cap exponential backoff at 15s
timeout: 20000, // connection timeout
// Note: pingTimeout and pingInterval are server-side only in Socket.IO v4.
// The server controls the heartbeat (ping_interval=25, ping_timeout=60).
}); });
socket.on('connect',()=>{console.log('[Socket] connected');}); socket.on('connect',()=>{console.log('[Socket] connected');});
socket.on('new_notification',(n)=>{ socket.on('new_notification',(n)=>{
@@ -466,48 +470,104 @@ function updateBadge(delta, increment=false){
// ── Notification panel ──────────────────────────────────────────────────────── // ── Notification panel ────────────────────────────────────────────────────────
let panelOpen = false; let panelOpen = false;
function toggleNotifPanel(){ function toggleNotifPanel(){
const panel = document.getElementById('notif-panel'); const panel = document.getElementById('notif-panel');
panelOpen = !panelOpen; panelOpen = !panelOpen;
panel.classList.toggle('show', panelOpen); panel.classList.toggle('show', panelOpen);
if(panelOpen) loadNotifications(); if(panelOpen) loadNotifications();
} }
document.addEventListener('click',(e)=>{
document.addEventListener('click', e => {
if(panelOpen && !e.target.closest('#notif-panel') && !e.target.closest('.notif-btn')){ if(panelOpen && !e.target.closest('#notif-panel') && !e.target.closest('.notif-btn')){
panelOpen = false; panelOpen = false;
document.getElementById('notif-panel').classList.remove('show'); document.getElementById('notif-panel').classList.remove('show');
} }
}); });
async function loadNotifications(){ function _notifItemHtml(n) {
try{ const unreadClass = n.is_read ? '' : ' unread';
const r = await fetch('/api/notifications/unread-count'); const dot = n.is_read ? '' : '<div class="notif-dot"></div>';
const d = await r.json(); return (
updateBadge(d.count); `<div class="notif-item${unreadClass}" data-id="${n.id}" data-link="${n.link || ''}" ` +
}catch(e){} `style="cursor:${n.link ? 'pointer' : 'default'}">` +
dot +
`<div style="flex:1;min-width:0;">` +
`<div class="notif-title">${n.title}</div>` +
`<div class="notif-msg">${n.message || ''}</div>` +
`<div class="notif-time">${n.created_at}</div>` +
`</div></div>`
);
}
function _bindNotifClick(el) {
el.addEventListener('click', async () => {
const id = el.dataset.id;
const link = el.dataset.link;
// Mark as read in DB
if(el.classList.contains('unread')){
try { await fetch(`/api/notifications/${id}/read`, { method: 'POST' }); } catch(_){}
el.classList.remove('unread');
const dot = el.querySelector('.notif-dot');
if(dot) dot.remove();
// Decrement badge
const badge = document.getElementById('notif-badge-count');
if(badge){
const cur = parseInt(badge.textContent) || 0;
const next = Math.max(0, cur - 1);
badge.textContent = next > 0 ? next : '';
badge.classList.toggle('show', next > 0);
}
}
if(link) window.location.href = link;
});
}
async function loadNotifications(){
const list = document.getElementById('notif-list'); const list = document.getElementById('notif-list');
list.innerHTML = '<div class="p-3 text-center" style="color:var(--muted);font-size:13px;">Loading…</div>';
try{ try{
const r = await fetch('/notifications?json=1'); const r = await fetch('/api/notifications');
// Fallback: show link if(!r.ok) throw new Error('HTTP ' + r.status);
list.innerHTML='<div class="p-3 text-center" style="color:var(--muted);font-size:13px;">'+ const data = await r.json();
'<a href="/notifications" style="color:var(--accent3)">View all notifications</a></div>'; const notifs = data.notifications || [];
// Update badge from live count
const unreadCount = notifs.filter(n => !n.is_read).length;
updateBadge(unreadCount);
if(notifs.length === 0){
list.innerHTML = '<div class="p-4 text-center" style="color:var(--muted);font-size:13px;">' +
'<i class="bi bi-bell-slash" style="font-size:24px;display:block;margin-bottom:8px;"></i>' +
'No notifications yet.</div>';
return;
}
list.innerHTML = notifs.map(_notifItemHtml).join('');
list.querySelectorAll('.notif-item').forEach(_bindNotifClick);
}catch(e){ }catch(e){
list.innerHTML='<div class="p-3 text-center" style="color:var(--muted);font-size:13px;">Unable to load</div>'; list.innerHTML = '<div class="p-3 text-center" style="color:var(--muted);font-size:13px;">Unable to load notifications.</div>';
} }
} }
function prependNotif(n){ function prependNotif(n){
// Add new real-time notification to the top of the panel if it's open
const list = document.getElementById('notif-list'); const list = document.getElementById('notif-list');
const item = document.createElement('div'); // Remove "no notifications" placeholder if present
item.className='notif-item unread'; if(list.querySelector('.bi-bell-slash')) list.innerHTML = '';
item.innerHTML=`<div class="notif-dot"></div><div><div class="notif-title">${n.title}</div><div class="notif-msg">${n.message||''}</div><div class="notif-time">Just now</div></div>`; const tmp = document.createElement('div');
if(n.link) item.onclick=()=>{ window.location=n.link; }; tmp.innerHTML = _notifItemHtml({
list.prepend(item); id: n.id, title: n.title, message: n.message || '',
link: n.link || '', is_read: false, created_at: 'Just now'
});
const el = tmp.firstElementChild;
_bindNotifClick(el);
list.prepend(el);
} }
async function markAllRead(){ async function markAllRead(){
await fetch('/api/notifications/mark-all-read',{method:'POST'}); try { await fetch('/api/notifications/mark-all-read', { method: 'POST' }); } catch(_){}
updateBadge(0); updateBadge(0);
document.querySelectorAll('.notif-item.unread').forEach(el => { document.querySelectorAll('.notif-item.unread').forEach(el => {
el.classList.remove('unread'); el.classList.remove('unread');
+1 -1
View File
@@ -215,7 +215,7 @@
<div style="display:flex;gap:10px;padding:7px 0;border-bottom:1px solid var(--border);"> <div style="display:flex;gap:10px;padding:7px 0;border-bottom:1px solid var(--border);">
<i class="bi {{ icon }}" style="color:var(--muted);width:16px;text-align:center;margin-top:1px;flex-shrink:0;"></i> <i class="bi {{ icon }}" style="color:var(--muted);width:16px;text-align:center;margin-top:1px;flex-shrink:0;"></i>
<div style="color:var(--muted);">{{ label }}</div> <div style="color:var(--muted);">{{ label }}</div>
<div style="margin-left:auto;text-align:right;max-width:55%;">{{ value }}</div> <div style="margin-left:auto;text-align:right;max-width:55%;">{{ value | safe }}</div>
</div> </div>
{% endmacro %} {% endmacro %}
{{ info_row('bi-hash', 'Number', '<span class="mono" style="font-size:11px;color:var(--accent3);">'~ticket.ticket_number~'</span>') }} {{ info_row('bi-hash', 'Number', '<span class="mono" style="font-size:11px;color:var(--accent3);">'~ticket.ticket_number~'</span>') }}
+36 -19
View File
@@ -1,36 +1,53 @@
# gunicorn.conf.py # gunicorn.conf.py
import multiprocessing
# Server socket # Server socket
bind = "127.0.0.1:7000" bind = "127.0.0.1:7000" # match your nginx proxy_pass port
backlog = 2048 backlog = 2048
# Worker processes # Worker — eventlet requires exactly 1 worker process.
worker_class = "eventlet" # Required for Flask-SocketIO # Concurrency is handled by eventlet's cooperative green-thread pool.
workers = 1 # eventlet requires exactly 1 worker worker_class = "eventlet"
worker_connections = 1000 workers = 1
timeout = 120 worker_connections= 2000
keepalive = 5 timeout = 300
keepalive = 75
# Preloading ensures eventlet.monkey_patch() in run.py runs once before fork
preload_app = True
# Application # Application
module = "run:app" wsgi_app = "run:app"
# Logging # Logging
accesslog = "logs/gunicorn_access.log" accesslog = "logs/gunicorn_access.log"
errorlog = "logs/gunicorn_error.log" errorlog = "logs/gunicorn_error.log"
loglevel = "info" loglevel = "warning" # suppress routine socket close noise
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)sµs' capture_output = False # don't capture eventlet's stderr Errno 9 messages
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(a)s" %(D)sµs'
# Process naming # Process naming
proc_name = "it_ticket_system" proc_name = "it_ticket_system"
# Server mechanics
daemon = False daemon = False
pidfile = "/tmp/it_tickets.pid" pidfile = "/tmp/it_tickets.pid"
user = None # Set to your app user, e.g. "www-data"
group = None
tmp_upload_dir = None
# SSL (configure if not using nginx for SSL termination)
# keyfile = "/etc/ssl/private/your.key" def post_fork(server, worker):
# certfile = "/etc/ssl/certs/your.crt" """Called after a worker is forked. Patch eventlet per-worker."""
import eventlet
eventlet.monkey_patch()
def worker_exit(server, worker):
"""
Called when a worker exits. Cleanly close any remaining eventlet sockets
to prevent [Errno 9] Bad file descriptor errors in the logs.
These errors are benign (connections recover automatically) but noisy.
"""
try:
import eventlet.greenio
# Hub cleanup — tells eventlet to stop watching all open file descriptors
hub = eventlet.hubs.get_hub()
if hasattr(hub, 'abort'):
hub.abort(wait=False)
except Exception:
pass
+8 -1
View File
@@ -1,7 +1,14 @@
# eventlet monkey-patching MUST happen before any other imports.
# Without this, eventlet cannot patch the standard library's socket/threading
# modules, which causes [Errno 9] Bad file descriptor errors under gunicorn
# and makes SMTP connections unreliable in background threads.
import eventlet
eventlet.monkey_patch()
import os import os
from app import create_app, socketio from app import create_app, socketio
app = create_app(os.environ.get('FLASK_ENV', 'production')) app = create_app(os.environ.get('FLASK_ENV', 'production'))
if __name__ == '__main__': if __name__ == '__main__':
socketio.run(app, host='0.0.0.0', port=5000, debug=app.debug) socketio.run(app, host='0.0.0.0', port=5500, debug=app.debug)