Enhance Ticket management functionalities
This commit is contained in:
+15
-1
@@ -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.'
|
||||||
|
|||||||
+24
-1
@@ -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():
|
||||||
@@ -80,4 +103,4 @@ def on_join_ticket(data):
|
|||||||
def on_leave_ticket(data):
|
def on_leave_ticket(data):
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
ticket_id = data.get('ticket_id')
|
ticket_id = data.get('ticket_id')
|
||||||
leave_room(f'ticket_{ticket_id}')
|
leave_room(f'ticket_{ticket_id}')
|
||||||
@@ -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
|
||||||
'id' : notif.id,
|
# request context. Inline emits during a polling→WebSocket upgrade can
|
||||||
'type' : notif_type,
|
# race with the upgrade handshake and disconnect the client.
|
||||||
'title' : title,
|
payload = {
|
||||||
'message' : message,
|
'id' : notif.id,
|
||||||
'link' : link,
|
'type' : notif_type,
|
||||||
|
'title' : title,
|
||||||
|
'message' : message,
|
||||||
|
'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,13 +106,30 @@ 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."""
|
"""
|
||||||
try:
|
Send an email in a background thread so it never blocks the request.
|
||||||
msg = Message(subject=subject, recipients=recipients, html=html_body)
|
|
||||||
mail.send(msg)
|
Flask-Mail opens an SMTP connection synchronously. Running it in the main
|
||||||
logger.info(f'[EMAIL SENT] subject="{subject}" to={recipients}')
|
request thread means a slow or failing SMTP server delays the entire
|
||||||
except Exception as exc:
|
response — including the WebSocket notification push that follows.
|
||||||
logger.error(f'[EMAIL ERROR] Failed to send "{subject}" to {recipients}: {exc}')
|
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 ─────────────────────────────────────────────────────
|
# ─── Ticket Event Helpers ─────────────────────────────────────────────────────
|
||||||
@@ -252,4 +280,4 @@ def notify_assignment(ticket, assigned_by):
|
|||||||
[ticket.assignee.email],
|
[ticket.assignee.email],
|
||||||
html,
|
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}')
|
||||||
+84
-24
@@ -442,11 +442,15 @@
|
|||||||
{% if current_user.is_authenticated %}
|
{% if current_user.is_authenticated %}
|
||||||
const socket = io({
|
const socket = io({
|
||||||
path: '/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,
|
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,52 +470,108 @@ 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');
|
||||||
const dot=el.querySelector('.notif-dot');
|
const dot = el.querySelector('.notif-dot');
|
||||||
if(dot) dot.remove();
|
if(dot) dot.remove();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>') }}
|
||||||
@@ -247,4 +247,4 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
+41
-24
@@ -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"
|
||||||
|
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)
|
def post_fork(server, worker):
|
||||||
# keyfile = "/etc/ssl/private/your.key"
|
"""Called after a worker is forked. Patch eventlet per-worker."""
|
||||||
# certfile = "/etc/ssl/certs/your.crt"
|
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
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user