import logging from flask import Blueprint, jsonify, request, abort 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') @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(): count = Notification.query.filter_by(user_id=current_user.id, is_read=False).count() return jsonify({'count': count}) @api_bp.route('/notifications//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 Comments API ────────────────────────────────────────────────────── @api_bp.route('/tickets//comments') @login_required def get_comments(ticket_id): """Return all visible comments for a ticket as JSON.""" from app.models import Ticket, Comment, UserRole ticket = db.session.get(Ticket, ticket_id) or abort(404) # Employees may only see their own tickets if not current_user.is_it_staff and ticket.created_by_id != current_user.id: return jsonify({'error': 'Forbidden'}), 403 q = Comment.query.filter_by(ticket_id=ticket_id) if not current_user.is_it_staff: q = q.filter_by(is_internal=False) comments = q.order_by(Comment.created_at.asc()).all() return jsonify({'comments': [ { 'id' : c.id, 'author_name': c.author.full_name, 'author_init': c.author.full_name[0].upper(), 'author_avatar': c.author.avatar_url or '', 'is_it_staff': c.author.is_it_staff, 'is_internal': c.is_internal, 'body' : c.body, 'created_at' : c.created_at.strftime('%b %d, %Y %H:%M'), 'can_delete' : current_user.is_it_staff or c.author_id == current_user.id, 'attachments': [ { 'id' : a.id, 'filename' : a.filename, 'mime_type': a.mime_type or '', 'is_image' : (a.mime_type or '').startswith('image/'), } for a in c.attachments.all() ], } for c in comments ]}) # ─── 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}') # ─── User Search API (IT Only) ──────────────────────────────────────────────── @api_bp.route('/users/search') @login_required def search_users(): """Return active employees matching a search query. Used by the 'create on behalf' form to populate the employee selector. Restricted to IT staff to prevent employees from enumerating all users. Query params ------------ q : str – search term matched against full_name, email, department limit : int – max results (default 20, max 50) """ if not current_user.is_it_staff: return jsonify({'error': 'Forbidden'}), 403 q = request.args.get('q', '').strip() limit = min(request.args.get('limit', 20, type=int), 50) from app.models import User, UserRole query = User.query.filter(User.is_active == True) if q: query = query.filter( User.full_name.ilike(f'%{q}%') | User.email.ilike(f'%{q}%') | User.department.ilike(f'%{q}%') ) users = query.order_by(User.full_name).limit(limit).all() return jsonify({'users': [ { 'id' : u.id, 'full_name' : u.full_name, 'email' : u.email, 'department': u.department or '', 'role' : u.role, } for u in users ]}) # ─── Ticket Search API ──────────────────────────────────────────────────────── @api_bp.route('/tickets/search') @login_required def search_tickets(): """Return tickets matching a query string, used by the link-ticket picker. Searches ticket_number and title (case-insensitive). ?q= — required, min 1 char ?exclude= — ticket ID to exclude (the current ticket) Restricted to IT staff only. """ if not current_user.is_it_staff: return jsonify({'error': 'Forbidden'}), 403 q = request.args.get('q', '').strip() exclude_id = request.args.get('exclude', 0, type=int) if not q: return jsonify({'tickets': []}) from app.models import Ticket query = Ticket.query.filter( Ticket.ticket_number.ilike(f'%{q}%') | Ticket.title.ilike(f'%{q}%') ) if exclude_id: query = query.filter(Ticket.id != exclude_id) results = query.order_by(Ticket.created_at.desc()).limit(10).all() logger.info(f'[TICKET SEARCH] q="{q}" results={len(results)} user_id={current_user.id}') return jsonify({'tickets': [ { 'id' : t.id, 'ticket_number': t.ticket_number, 'title' : t.title, 'status' : t.status, 'priority' : t.priority, } for t in results ]})