From 49542d1f144497f7dbda83a1f665f7493f26edfd Mon Sep 17 00:00:00 2001 From: NguyenND Date: Wed, 25 Mar 2026 17:33:34 -0400 Subject: [PATCH] Enhance Ticket management functionalities --- app/__init__.py | 16 +++- app/routes/api.py | 25 ++++++- app/services/notification_service.py | 60 +++++++++++---- app/templates/base.html | 108 +++++++++++++++++++++------ app/templates/tickets/detail.html | 4 +- gunicorn.conf.py | 65 ++++++++++------ run.py | 9 ++- 7 files changed, 218 insertions(+), 69 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 35e7219..a6990ab 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -32,7 +32,21 @@ def create_app(config_name=None): login_manager.init_app(app) mail.init_app(app) 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_message = 'Please log in to access this page.' diff --git a/app/routes/api.py b/app/routes/api.py index 75dadb3..3d6cfbd 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -12,6 +12,29 @@ logger = logging.getLogger(__name__) # ─── 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') @login_required def unread_count(): @@ -80,4 +103,4 @@ def on_join_ticket(data): def on_leave_ticket(data): if current_user.is_authenticated: ticket_id = data.get('ticket_id') - leave_room(f'ticket_{ticket_id}') + leave_room(f'ticket_{ticket_id}') \ No newline at end of file diff --git a/app/services/notification_service.py b/app/services/notification_service.py index ba2df7b..031633a 100644 --- a/app/services/notification_service.py +++ b/app/services/notification_service.py @@ -77,15 +77,26 @@ def create_notification(user_id, notif_type, title, message, ticket_id=None, lin db.session.commit() logger.info(f'[NOTIFICATION CREATE] user_id={user_id} type={notif_type} ticket_id={ticket_id}') - # Real-time push - socketio.emit('new_notification', { - 'id' : notif.id, - 'type' : notif_type, - 'title' : title, - 'message' : message, - 'link' : link, + # Real-time push — schedule via socketio.start_background_task so the + # 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, + 'type' : notif_type, + 'title' : title, + 'message' : message, + 'link' : link, '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: db.session.rollback() @@ -95,13 +106,30 @@ def create_notification(user_id, notif_type, title, message, ticket_id=None, lin # ─── Email Notification ─────────────────────────────────────────────────────── def send_email(subject, recipients, html_body): - """Send an email; silently log errors so it never blocks the main flow.""" - try: - msg = Message(subject=subject, recipients=recipients, html=html_body) - mail.send(msg) - logger.info(f'[EMAIL SENT] subject="{subject}" to={recipients}') - except Exception as exc: - logger.error(f'[EMAIL ERROR] Failed to send "{subject}" to {recipients}: {exc}') + """ + 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: + msg = Message(subject=subject, recipients=recipients, html=html_body) + mail.send(msg) + logger.info(f'[EMAIL SENT] subject="{subject}" to={recipients}') + except Exception as exc: + logger.error(f'[EMAIL ERROR] Failed to send "{subject}" to {recipients}: {exc}') + + t = Thread(target=_send, daemon=True) + t.start() # ─── Ticket Event Helpers ───────────────────────────────────────────────────── @@ -252,4 +280,4 @@ def notify_assignment(ticket, assigned_by): [ticket.assignee.email], html, ) - logger.info(f'[TICKET ASSIGN] ticket_id={ticket.id} assigned_to={ticket.assigned_to_id} by={assigned_by.id}') + logger.info(f'[TICKET ASSIGN] ticket_id={ticket.id} assigned_to={ticket.assigned_to_id} by={assigned_by.id}') \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index 017c325..9179c66 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -442,11 +442,15 @@ {% if current_user.is_authenticated %} const socket = io({ path: '/socket.io/', - transports: ['polling', 'websocket'], // polling first → upgrade to WS after handshake + transports: ['polling', 'websocket'], // polling first → upgrade to WS after handshake upgrade: true, reconnection: true, - reconnectionAttempts: 5, - reconnectionDelay: 2000, + reconnectionAttempts: 10, + 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('new_notification',(n)=>{ @@ -466,52 +470,108 @@ function updateBadge(delta, increment=false){ // ── Notification panel ──────────────────────────────────────────────────────── let panelOpen = false; + function toggleNotifPanel(){ const panel = document.getElementById('notif-panel'); panelOpen = !panelOpen; panel.classList.toggle('show', panelOpen); if(panelOpen) loadNotifications(); } -document.addEventListener('click',(e)=>{ + +document.addEventListener('click', e => { if(panelOpen && !e.target.closest('#notif-panel') && !e.target.closest('.notif-btn')){ - panelOpen=false; + panelOpen = false; document.getElementById('notif-panel').classList.remove('show'); } }); -async function loadNotifications(){ - try{ - const r = await fetch('/api/notifications/unread-count'); - const d = await r.json(); - updateBadge(d.count); - }catch(e){} +function _notifItemHtml(n) { + const unreadClass = n.is_read ? '' : ' unread'; + const dot = n.is_read ? '' : '
'; + return ( + `
` + + dot + + `
` + + `
${n.title}
` + + `
${n.message || ''}
` + + `
${n.created_at}
` + + `
` + ); +} +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'); + list.innerHTML = '
Loading…
'; try{ - const r = await fetch('/notifications?json=1'); - // Fallback: show link - list.innerHTML='
'+ - 'View all notifications
'; + const r = await fetch('/api/notifications'); + if(!r.ok) throw new Error('HTTP ' + r.status); + const data = await r.json(); + 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 = '
' + + '' + + 'No notifications yet.
'; + return; + } + + list.innerHTML = notifs.map(_notifItemHtml).join(''); + list.querySelectorAll('.notif-item').forEach(_bindNotifClick); + }catch(e){ - list.innerHTML='
Unable to load
'; + list.innerHTML = '
Unable to load notifications.
'; } } 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 item = document.createElement('div'); - item.className='notif-item unread'; - item.innerHTML=`
${n.title}
${n.message||''}
Just now
`; - if(n.link) item.onclick=()=>{ window.location=n.link; }; - list.prepend(item); + // Remove "no notifications" placeholder if present + if(list.querySelector('.bi-bell-slash')) list.innerHTML = ''; + const tmp = document.createElement('div'); + tmp.innerHTML = _notifItemHtml({ + 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(){ - await fetch('/api/notifications/mark-all-read',{method:'POST'}); + try { await fetch('/api/notifications/mark-all-read', { method: 'POST' }); } catch(_){} updateBadge(0); - document.querySelectorAll('.notif-item.unread').forEach(el=>{ + document.querySelectorAll('.notif-item.unread').forEach(el => { el.classList.remove('unread'); - const dot=el.querySelector('.notif-dot'); + const dot = el.querySelector('.notif-dot'); if(dot) dot.remove(); }); } diff --git a/app/templates/tickets/detail.html b/app/templates/tickets/detail.html index ba7b953..37c708a 100644 --- a/app/templates/tickets/detail.html +++ b/app/templates/tickets/detail.html @@ -215,7 +215,7 @@
{{ label }}
-
{{ value }}
+
{{ value | safe }}
{% endmacro %} {{ info_row('bi-hash', 'Number', ''~ticket.ticket_number~'') }} @@ -247,4 +247,4 @@ {% endif %} -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/gunicorn.conf.py b/gunicorn.conf.py index 774a0c1..f4fdc7e 100644 --- a/gunicorn.conf.py +++ b/gunicorn.conf.py @@ -1,36 +1,53 @@ # gunicorn.conf.py -import multiprocessing # Server socket -bind = "127.0.0.1:7000" -backlog = 2048 +bind = "127.0.0.1:7000" # match your nginx proxy_pass port +backlog = 2048 -# Worker processes -worker_class = "eventlet" # Required for Flask-SocketIO -workers = 1 # eventlet requires exactly 1 worker -worker_connections = 1000 -timeout = 120 -keepalive = 5 +# Worker — eventlet requires exactly 1 worker process. +# Concurrency is handled by eventlet's cooperative green-thread pool. +worker_class = "eventlet" +workers = 1 +worker_connections= 2000 +timeout = 300 +keepalive = 75 + +# Preloading ensures eventlet.monkey_patch() in run.py runs once before fork +preload_app = True # Application -module = "run:app" +wsgi_app = "run:app" # Logging -accesslog = "logs/gunicorn_access.log" -errorlog = "logs/gunicorn_error.log" -loglevel = "info" -access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)sµs' +accesslog = "logs/gunicorn_access.log" +errorlog = "logs/gunicorn_error.log" +loglevel = "warning" # suppress routine socket close noise +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 -proc_name = "it_ticket_system" +proc_name = "it_ticket_system" +daemon = False +pidfile = "/tmp/it_tickets.pid" -# Server mechanics -daemon = False -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" -# certfile = "/etc/ssl/certs/your.crt" +def post_fork(server, worker): + """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 \ No newline at end of file diff --git a/run.py b/run.py index 047a5e2..5f7885a 100644 --- a/run.py +++ b/run.py @@ -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 from app import create_app, socketio app = create_app(os.environ.get('FLASK_ENV', 'production')) 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) \ No newline at end of file