From c63db708cc1e21c272d89daa910ace81faecdf1e Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Sun, 18 Jan 2026 23:23:46 +0300 Subject: [PATCH 1/5] feat(tickets): notify admins on new ticket creation and replies - Added functionality to notify admins when a new ticket is created. - Implemented notification for admins when a user replies to a ticket. - Included error handling for notification failures. --- app/cabinet/routes/tickets.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/cabinet/routes/tickets.py b/app/cabinet/routes/tickets.py index 9547b8cf..448c870f 100644 --- a/app/cabinet/routes/tickets.py +++ b/app/cabinet/routes/tickets.py @@ -12,6 +12,7 @@ from sqlalchemy.orm import selectinload from app.database.models import User, Ticket, TicketMessage from app.config import settings +from app.handlers.tickets import notify_admins_about_new_ticket, notify_admins_about_ticket_reply from ..dependencies import get_cabinet_db, get_current_cabinet_user from ..schemas.tickets import ( @@ -161,6 +162,12 @@ async def create_ticket( # Refresh to get relationships await db.refresh(ticket, ["messages"]) + # Уведомить админов о новом тикете + try: + await notify_admins_about_new_ticket(ticket, db) + except Exception as e: + logger.error(f"Error notifying admins about new ticket from cabinet: {e}") + messages = [_message_to_response(m) for m in ticket.messages] return TicketDetailResponse( @@ -268,4 +275,10 @@ async def add_ticket_message( await db.commit() await db.refresh(message) + # Уведомить админов об ответе пользователя + try: + await notify_admins_about_ticket_reply(ticket, request.message, db) + except Exception as e: + logger.error(f"Error notifying admins about ticket reply from cabinet: {e}") + return _message_to_response(message) From 67c3dba1ccd2677d118e1fa68afcc213a36373f9 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 19 Jan 2026 00:02:41 +0300 Subject: [PATCH 2/5] feat(notifications): implement ticket notifications for users and admins - Added a new TicketNotification model to handle notifications for ticket events. - Implemented user and admin notifications for new tickets and replies in the cabinet. - Introduced settings to enable or disable notifications for users and admins. - Enhanced ticket settings to include notification preferences. - Integrated WebSocket notifications for real-time updates. --- app/cabinet/routes/__init__.py | 8 + app/cabinet/routes/admin_tickets.py | 32 +++ app/cabinet/routes/ticket_notifications.py | 186 ++++++++++++++++ app/cabinet/routes/tickets.py | 26 ++- app/cabinet/routes/websocket.py | 235 ++++++++++++++++++++ app/database/crud/ticket_notification.py | 246 +++++++++++++++++++++ app/database/models.py | 34 +++ app/services/support_settings_service.py | 29 +++ 8 files changed, 794 insertions(+), 2 deletions(-) create mode 100644 app/cabinet/routes/ticket_notifications.py create mode 100644 app/cabinet/routes/websocket.py create mode 100644 app/database/crud/ticket_notification.py diff --git a/app/cabinet/routes/__init__.py b/app/cabinet/routes/__init__.py index 035ca3d2..efaebfff 100644 --- a/app/cabinet/routes/__init__.py +++ b/app/cabinet/routes/__init__.py @@ -7,6 +7,8 @@ from .subscription import router as subscription_router from .balance import router as balance_router from .referral import router as referral_router from .tickets import router as tickets_router +from .ticket_notifications import router as ticket_notifications_router +from .ticket_notifications import admin_router as admin_ticket_notifications_router from .admin_tickets import router as admin_tickets_router from .admin_settings import router as admin_settings_router from .admin_apps import router as admin_apps_router @@ -32,6 +34,7 @@ from .admin_payments import router as admin_payments_router from .admin_promo_offers import router as admin_promo_offers_router from .admin_remnawave import router as admin_remnawave_router from .media import router as media_router +from .websocket import router as websocket_router # Main cabinet router router = APIRouter(prefix="/cabinet", tags=["Cabinet"]) @@ -42,6 +45,7 @@ router.include_router(subscription_router) router.include_router(balance_router) router.include_router(referral_router) router.include_router(tickets_router) +router.include_router(ticket_notifications_router) router.include_router(promocode_router) router.include_router(contests_router) router.include_router(polls_router) @@ -56,6 +60,7 @@ router.include_router(wheel_router) # Admin routes router.include_router(admin_tickets_router) +router.include_router(admin_ticket_notifications_router) router.include_router(admin_settings_router) router.include_router(admin_apps_router) router.include_router(admin_wheel_router) @@ -72,4 +77,7 @@ router.include_router(admin_payments_router) router.include_router(admin_promo_offers_router) router.include_router(admin_remnawave_router) +# WebSocket route +router.include_router(websocket_router) + __all__ = ["router"] diff --git a/app/cabinet/routes/admin_tickets.py b/app/cabinet/routes/admin_tickets.py index cfef4544..9902461d 100644 --- a/app/cabinet/routes/admin_tickets.py +++ b/app/cabinet/routes/admin_tickets.py @@ -13,7 +13,9 @@ from pydantic import BaseModel, Field from app.database.models import User, Ticket, TicketMessage from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD +from app.database.crud.ticket_notification import TicketNotificationCRUD from app.config import settings +from app.cabinet.routes.websocket import notify_user_ticket_reply from ..dependencies import get_cabinet_db, get_current_admin_user from ..schemas.tickets import TicketMessageResponse @@ -110,6 +112,9 @@ class TicketSettingsResponse(BaseModel): sla_check_interval_seconds: int sla_reminder_cooldown_minutes: int support_system_mode: str # tickets, contact, both + # Cabinet notifications settings + cabinet_user_notifications_enabled: bool = True + cabinet_admin_notifications_enabled: bool = True class TicketSettingsUpdateRequest(BaseModel): @@ -119,6 +124,9 @@ class TicketSettingsUpdateRequest(BaseModel): sla_check_interval_seconds: Optional[int] = Field(None, ge=30, le=600, description="Check interval (30-600 seconds)") sla_reminder_cooldown_minutes: Optional[int] = Field(None, ge=1, le=120, description="Reminder cooldown (1-120 minutes)") support_system_mode: Optional[str] = Field(None, description="Support mode: tickets, contact, both") + # Cabinet notifications settings + cabinet_user_notifications_enabled: Optional[bool] = Field(None, description="Enable user notifications in cabinet") + cabinet_admin_notifications_enabled: Optional[bool] = Field(None, description="Enable admin notifications in cabinet") def _message_to_response(message: TicketMessage) -> TicketMessageResponse: @@ -348,6 +356,17 @@ async def reply_to_ticket( except Exception as e: logger.warning(f"Failed to send Telegram notification: {e}") + # Уведомить пользователя в кабинете + try: + notification = await TicketNotificationCRUD.create_user_notification_for_admin_reply( + db, ticket, request.message + ) + if notification: + # Отправить WebSocket уведомление + await notify_user_ticket_reply(ticket.user_id, ticket.id, (request.message or "")[:100]) + except Exception as e: + logger.warning(f"Failed to create cabinet notification for admin reply: {e}") + return _message_to_response(message) @@ -475,12 +494,16 @@ async def get_ticket_settings( db: AsyncSession = Depends(get_cabinet_db), ): """Get ticket system settings.""" + from app.services.support_settings_service import SupportSettingsService + return TicketSettingsResponse( sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED, sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES, sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS, sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES, support_system_mode=settings.get_support_system_mode(), + cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(), + cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(), ) @@ -493,6 +516,7 @@ async def update_ticket_settings( """Update ticket system settings.""" import os from pathlib import Path + from app.services.support_settings_service import SupportSettingsService # Validate support_system_mode if request.support_system_mode is not None: @@ -515,6 +539,12 @@ async def update_ticket_settings( if request.support_system_mode is not None: settings.SUPPORT_SYSTEM_MODE = request.support_system_mode.strip().lower() + # Update cabinet notification settings + if request.cabinet_user_notifications_enabled is not None: + SupportSettingsService.set_cabinet_user_notifications_enabled(request.cabinet_user_notifications_enabled) + if request.cabinet_admin_notifications_enabled is not None: + SupportSettingsService.set_cabinet_admin_notifications_enabled(request.cabinet_admin_notifications_enabled) + # Try to persist to .env file try: env_file = Path(".env") @@ -563,4 +593,6 @@ async def update_ticket_settings( sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS, sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES, support_system_mode=settings.get_support_system_mode(), + cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(), + cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(), ) diff --git a/app/cabinet/routes/ticket_notifications.py b/app/cabinet/routes/ticket_notifications.py new file mode 100644 index 00000000..7b76685f --- /dev/null +++ b/app/cabinet/routes/ticket_notifications.py @@ -0,0 +1,186 @@ +"""Ticket notifications routes for cabinet.""" + +import logging +from datetime import datetime +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel + +from app.database.models import User +from app.database.crud.ticket_notification import TicketNotificationCRUD + +from ..dependencies import get_cabinet_db, get_current_cabinet_user, get_current_admin_user + + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/tickets/notifications", tags=["Cabinet Ticket Notifications"]) +admin_router = APIRouter(prefix="/admin/tickets/notifications", tags=["Cabinet Admin Ticket Notifications"]) + + +# Schemas +class TicketNotificationResponse(BaseModel): + """Single ticket notification.""" + id: int + ticket_id: int + notification_type: str + message: Optional[str] = None + is_read: bool + created_at: datetime + read_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class TicketNotificationListResponse(BaseModel): + """List of ticket notifications.""" + items: List[TicketNotificationResponse] + unread_count: int + + +class UnreadCountResponse(BaseModel): + """Unread notifications count.""" + unread_count: int + + +# User endpoints +@router.get("", response_model=TicketNotificationListResponse) +async def get_user_notifications( + unread_only: bool = Query(False, description="Only return unread notifications"), + limit: int = Query(50, ge=1, le=100), + offset: int = Query(0, ge=0), + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get ticket notifications for current user.""" + notifications = await TicketNotificationCRUD.get_user_notifications( + db, user.id, unread_only=unread_only, limit=limit, offset=offset + ) + unread_count = await TicketNotificationCRUD.count_unread_user(db, user.id) + + return TicketNotificationListResponse( + items=[TicketNotificationResponse.model_validate(n) for n in notifications], + unread_count=unread_count, + ) + + +@router.get("/unread-count", response_model=UnreadCountResponse) +async def get_user_unread_count( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get unread notifications count for current user.""" + count = await TicketNotificationCRUD.count_unread_user(db, user.id) + return UnreadCountResponse(unread_count=count) + + +@router.post("/{notification_id}/read") +async def mark_notification_as_read( + notification_id: int, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Mark a notification as read.""" + success = await TicketNotificationCRUD.mark_as_read(db, notification_id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Notification not found", + ) + return {"success": True} + + +@router.post("/read-all") +async def mark_all_notifications_as_read( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Mark all notifications as read for current user.""" + count = await TicketNotificationCRUD.mark_all_as_read_user(db, user.id) + return {"success": True, "marked_count": count} + + +@router.post("/ticket/{ticket_id}/read") +async def mark_ticket_notifications_as_read( + ticket_id: int, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Mark all notifications for a specific ticket as read.""" + count = await TicketNotificationCRUD.mark_ticket_notifications_as_read( + db, ticket_id, user.id, is_admin=False + ) + return {"success": True, "marked_count": count} + + +# Admin endpoints +@admin_router.get("", response_model=TicketNotificationListResponse) +async def get_admin_notifications( + unread_only: bool = Query(False, description="Only return unread notifications"), + limit: int = Query(50, ge=1, le=100), + offset: int = Query(0, ge=0), + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get ticket notifications for admins.""" + notifications = await TicketNotificationCRUD.get_admin_notifications( + db, unread_only=unread_only, limit=limit, offset=offset + ) + unread_count = await TicketNotificationCRUD.count_unread_admin(db) + + return TicketNotificationListResponse( + items=[TicketNotificationResponse.model_validate(n) for n in notifications], + unread_count=unread_count, + ) + + +@admin_router.get("/unread-count", response_model=UnreadCountResponse) +async def get_admin_unread_count( + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get unread notifications count for admins.""" + count = await TicketNotificationCRUD.count_unread_admin(db) + return UnreadCountResponse(unread_count=count) + + +@admin_router.post("/{notification_id}/read") +async def mark_admin_notification_as_read( + notification_id: int, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Mark an admin notification as read.""" + success = await TicketNotificationCRUD.mark_as_read(db, notification_id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Notification not found", + ) + return {"success": True} + + +@admin_router.post("/read-all") +async def mark_all_admin_notifications_as_read( + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Mark all admin notifications as read.""" + count = await TicketNotificationCRUD.mark_all_as_read_admin(db) + return {"success": True, "marked_count": count} + + +@admin_router.post("/ticket/{ticket_id}/read") +async def mark_admin_ticket_notifications_as_read( + ticket_id: int, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Mark all admin notifications for a specific ticket as read.""" + count = await TicketNotificationCRUD.mark_ticket_notifications_as_read( + db, ticket_id, admin.id, is_admin=True + ) + return {"success": True, "marked_count": count} diff --git a/app/cabinet/routes/tickets.py b/app/cabinet/routes/tickets.py index 448c870f..419132fc 100644 --- a/app/cabinet/routes/tickets.py +++ b/app/cabinet/routes/tickets.py @@ -13,6 +13,8 @@ from sqlalchemy.orm import selectinload from app.database.models import User, Ticket, TicketMessage from app.config import settings from app.handlers.tickets import notify_admins_about_new_ticket, notify_admins_about_ticket_reply +from app.database.crud.ticket_notification import TicketNotificationCRUD +from app.cabinet.routes.websocket import notify_admins_new_ticket, notify_admins_ticket_reply from ..dependencies import get_cabinet_db, get_current_cabinet_user from ..schemas.tickets import ( @@ -162,12 +164,21 @@ async def create_ticket( # Refresh to get relationships await db.refresh(ticket, ["messages"]) - # Уведомить админов о новом тикете + # Уведомить админов о новом тикете (Telegram) try: await notify_admins_about_new_ticket(ticket, db) except Exception as e: logger.error(f"Error notifying admins about new ticket from cabinet: {e}") + # Уведомить админов в кабинете + try: + notification = await TicketNotificationCRUD.create_admin_notification_for_new_ticket(db, ticket) + if notification: + # Отправить WebSocket уведомление + await notify_admins_new_ticket(ticket.id, ticket.title, user.id) + except Exception as e: + logger.error(f"Error creating cabinet notification for new ticket: {e}") + messages = [_message_to_response(m) for m in ticket.messages] return TicketDetailResponse( @@ -275,10 +286,21 @@ async def add_ticket_message( await db.commit() await db.refresh(message) - # Уведомить админов об ответе пользователя + # Уведомить админов об ответе пользователя (Telegram) try: await notify_admins_about_ticket_reply(ticket, request.message, db) except Exception as e: logger.error(f"Error notifying admins about ticket reply from cabinet: {e}") + # Уведомить админов в кабинете + try: + notification = await TicketNotificationCRUD.create_admin_notification_for_user_reply( + db, ticket, request.message + ) + if notification: + # Отправить WebSocket уведомление + await notify_admins_ticket_reply(ticket.id, (request.message or "")[:100], user.id) + except Exception as e: + logger.error(f"Error creating cabinet notification for user reply: {e}") + return _message_to_response(message) diff --git a/app/cabinet/routes/websocket.py b/app/cabinet/routes/websocket.py new file mode 100644 index 00000000..62f309fb --- /dev/null +++ b/app/cabinet/routes/websocket.py @@ -0,0 +1,235 @@ +"""WebSocket endpoint for cabinet real-time notifications.""" + +from __future__ import annotations + +import asyncio +import json +import logging +from typing import Any, Dict, Set + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +from app.database.database import AsyncSessionLocal +from app.database.crud.user import get_user_by_id +from app.config import settings +from app.cabinet.auth.jwt_handler import get_token_payload + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +class CabinetConnectionManager: + """Менеджер WebSocket подключений для кабинета.""" + + def __init__(self): + # user_id -> set of websocket connections + self._user_connections: Dict[int, Set[WebSocket]] = {} + # admin user_ids -> set of websocket connections + self._admin_connections: Dict[int, Set[WebSocket]] = {} + self._lock = asyncio.Lock() + + async def connect(self, websocket: WebSocket, user_id: int, is_admin: bool) -> None: + """Зарегистрировать подключение.""" + async with self._lock: + if user_id not in self._user_connections: + self._user_connections[user_id] = set() + self._user_connections[user_id].add(websocket) + + if is_admin: + if user_id not in self._admin_connections: + self._admin_connections[user_id] = set() + self._admin_connections[user_id].add(websocket) + + logger.info( + "Cabinet WS connected: user_id=%d, is_admin=%s, total_users=%d", + user_id, is_admin, len(self._user_connections) + ) + + async def disconnect(self, websocket: WebSocket, user_id: int) -> None: + """Отменить регистрацию подключения.""" + async with self._lock: + if user_id in self._user_connections: + self._user_connections[user_id].discard(websocket) + if not self._user_connections[user_id]: + del self._user_connections[user_id] + + if user_id in self._admin_connections: + self._admin_connections[user_id].discard(websocket) + if not self._admin_connections[user_id]: + del self._admin_connections[user_id] + + logger.info("Cabinet WS disconnected: user_id=%d", user_id) + + async def send_to_user(self, user_id: int, message: dict) -> None: + """Отправить сообщение конкретному пользователю.""" + connections = self._user_connections.get(user_id, set()) + if not connections: + return + + disconnected = set() + data = json.dumps(message, default=str, ensure_ascii=False) + + for ws in connections: + try: + await ws.send_text(data) + except Exception as e: + logger.warning("Failed to send to user %d: %s", user_id, e) + disconnected.add(ws) + + # Cleanup disconnected + async with self._lock: + for ws in disconnected: + self._user_connections.get(user_id, set()).discard(ws) + + async def send_to_admins(self, message: dict) -> None: + """Отправить сообщение всем админам.""" + if not self._admin_connections: + return + + data = json.dumps(message, default=str, ensure_ascii=False) + disconnected_by_user: Dict[int, Set[WebSocket]] = {} + + for user_id, connections in self._admin_connections.items(): + for ws in connections: + try: + await ws.send_text(data) + except Exception as e: + logger.warning("Failed to send to admin %d: %s", user_id, e) + if user_id not in disconnected_by_user: + disconnected_by_user[user_id] = set() + disconnected_by_user[user_id].add(ws) + + # Cleanup disconnected + async with self._lock: + for user_id, ws_set in disconnected_by_user.items(): + for ws in ws_set: + self._admin_connections.get(user_id, set()).discard(ws) + + +# Глобальный менеджер подключений +cabinet_ws_manager = CabinetConnectionManager() + + +async def verify_cabinet_ws_token(token: str) -> tuple[int | None, bool]: + """ + Проверить JWT токен для WebSocket. + + Returns: + tuple[user_id, is_admin] или (None, False) если токен невалидный + """ + if not token: + return None, False + + payload = get_token_payload(token, expected_type="access") + if not payload: + return None, False + + try: + user_id = int(payload.get("sub")) + except (TypeError, ValueError): + return None, False + + async with AsyncSessionLocal() as db: + user = await get_user_by_id(db, user_id) + if not user or user.status != "active": + return None, False + + is_admin = settings.is_admin(user.telegram_id) + return user_id, is_admin + + +@router.websocket("/ws") +async def cabinet_websocket_endpoint(websocket: WebSocket): + """WebSocket endpoint для real-time уведомлений кабинета.""" + client_host = websocket.client.host if websocket.client else "unknown" + + # Получаем токен из query params + token = websocket.query_params.get("token") + + if not token: + logger.warning("Cabinet WS: No token from %s", client_host) + await websocket.close(code=1008, reason="Unauthorized: No token") + return + + # Верифицируем токен + user_id, is_admin = await verify_cabinet_ws_token(token) + + if not user_id: + logger.warning("Cabinet WS: Invalid token from %s", client_host) + await websocket.close(code=1008, reason="Unauthorized: Invalid token") + return + + # Принимаем соединение + try: + await websocket.accept() + logger.info("Cabinet WS accepted: user_id=%d, is_admin=%s", user_id, is_admin) + except Exception as e: + logger.error("Cabinet WS: Failed to accept from %s: %s", client_host, e) + return + + # Регистрируем подключение + await cabinet_ws_manager.connect(websocket, user_id, is_admin) + + try: + # Приветственное сообщение + await websocket.send_json({ + "type": "connected", + "user_id": user_id, + "is_admin": is_admin, + }) + + # Обрабатываем входящие сообщения + while True: + try: + data = await websocket.receive_text() + message = json.loads(data) + + # Ping/pong для keepalive + if message.get("type") == "ping": + await websocket.send_json({"type": "pong"}) + + except json.JSONDecodeError: + logger.warning("Cabinet WS: Invalid JSON from user %d", user_id) + except WebSocketDisconnect: + break + except Exception as e: + logger.exception("Cabinet WS error for user %d: %s", user_id, e) + break + + except WebSocketDisconnect: + logger.info("Cabinet WS disconnected: user_id=%d", user_id) + except Exception as e: + logger.exception("Cabinet WS error: %s", e) + finally: + await cabinet_ws_manager.disconnect(websocket, user_id) + + +# Функции для отправки уведомлений (используются из других модулей) +async def notify_user_ticket_reply(user_id: int, ticket_id: int, message: str) -> None: + """Уведомить пользователя об ответе в тикете.""" + await cabinet_ws_manager.send_to_user(user_id, { + "type": "ticket.admin_reply", + "ticket_id": ticket_id, + "message": message, + }) + + +async def notify_admins_new_ticket(ticket_id: int, title: str, user_id: int) -> None: + """Уведомить админов о новом тикете.""" + await cabinet_ws_manager.send_to_admins({ + "type": "ticket.new", + "ticket_id": ticket_id, + "title": title, + "user_id": user_id, + }) + + +async def notify_admins_ticket_reply(ticket_id: int, message: str, user_id: int) -> None: + """Уведомить админов об ответе пользователя.""" + await cabinet_ws_manager.send_to_admins({ + "type": "ticket.user_reply", + "ticket_id": ticket_id, + "message": message, + "user_id": user_id, + }) diff --git a/app/database/crud/ticket_notification.py b/app/database/crud/ticket_notification.py new file mode 100644 index 00000000..fa7c1f01 --- /dev/null +++ b/app/database/crud/ticket_notification.py @@ -0,0 +1,246 @@ +"""CRUD operations for TicketNotification.""" + +import logging +from datetime import datetime +from typing import List, Optional + +from sqlalchemy import select, func, desc, and_, update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import TicketNotification, Ticket, User + + +logger = logging.getLogger(__name__) + + +class TicketNotificationCRUD: + """CRUD operations for ticket notifications in cabinet.""" + + @staticmethod + async def create( + db: AsyncSession, + ticket_id: int, + user_id: int, + notification_type: str, + message: Optional[str] = None, + is_for_admin: bool = False, + ) -> TicketNotification: + """Create a new ticket notification.""" + notification = TicketNotification( + ticket_id=ticket_id, + user_id=user_id, + notification_type=notification_type, + message=message, + is_for_admin=is_for_admin, + is_read=False, + created_at=datetime.utcnow(), + ) + db.add(notification) + await db.commit() + await db.refresh(notification) + return notification + + @staticmethod + async def get_user_notifications( + db: AsyncSession, + user_id: int, + unread_only: bool = False, + limit: int = 50, + offset: int = 0, + ) -> List[TicketNotification]: + """Get notifications for a user (not admin).""" + query = ( + select(TicketNotification) + .where( + TicketNotification.user_id == user_id, + TicketNotification.is_for_admin == False, + ) + .options(selectinload(TicketNotification.ticket)) + .order_by(desc(TicketNotification.created_at)) + ) + + if unread_only: + query = query.where(TicketNotification.is_read == False) + + query = query.offset(offset).limit(limit) + result = await db.execute(query) + return list(result.scalars().all()) + + @staticmethod + async def get_admin_notifications( + db: AsyncSession, + unread_only: bool = False, + limit: int = 50, + offset: int = 0, + ) -> List[TicketNotification]: + """Get notifications for admins.""" + query = ( + select(TicketNotification) + .where(TicketNotification.is_for_admin == True) + .options(selectinload(TicketNotification.ticket)) + .order_by(desc(TicketNotification.created_at)) + ) + + if unread_only: + query = query.where(TicketNotification.is_read == False) + + query = query.offset(offset).limit(limit) + result = await db.execute(query) + return list(result.scalars().all()) + + @staticmethod + async def count_unread_user(db: AsyncSession, user_id: int) -> int: + """Count unread notifications for a user.""" + query = select(func.count()).select_from(TicketNotification).where( + TicketNotification.user_id == user_id, + TicketNotification.is_for_admin == False, + TicketNotification.is_read == False, + ) + result = await db.execute(query) + return result.scalar() or 0 + + @staticmethod + async def count_unread_admin(db: AsyncSession) -> int: + """Count unread notifications for admins.""" + query = select(func.count()).select_from(TicketNotification).where( + TicketNotification.is_for_admin == True, + TicketNotification.is_read == False, + ) + result = await db.execute(query) + return result.scalar() or 0 + + @staticmethod + async def mark_as_read(db: AsyncSession, notification_id: int) -> bool: + """Mark a notification as read.""" + query = ( + update(TicketNotification) + .where(TicketNotification.id == notification_id) + .values(is_read=True, read_at=datetime.utcnow()) + ) + result = await db.execute(query) + await db.commit() + return result.rowcount > 0 + + @staticmethod + async def mark_all_as_read_user(db: AsyncSession, user_id: int) -> int: + """Mark all notifications as read for a user.""" + query = ( + update(TicketNotification) + .where( + TicketNotification.user_id == user_id, + TicketNotification.is_for_admin == False, + TicketNotification.is_read == False, + ) + .values(is_read=True, read_at=datetime.utcnow()) + ) + result = await db.execute(query) + await db.commit() + return result.rowcount + + @staticmethod + async def mark_all_as_read_admin(db: AsyncSession) -> int: + """Mark all admin notifications as read.""" + query = ( + update(TicketNotification) + .where( + TicketNotification.is_for_admin == True, + TicketNotification.is_read == False, + ) + .values(is_read=True, read_at=datetime.utcnow()) + ) + result = await db.execute(query) + await db.commit() + return result.rowcount + + @staticmethod + async def mark_ticket_notifications_as_read( + db: AsyncSession, ticket_id: int, user_id: int, is_admin: bool = False + ) -> int: + """Mark all notifications for a specific ticket as read.""" + query = ( + update(TicketNotification) + .where( + TicketNotification.ticket_id == ticket_id, + TicketNotification.is_read == False, + ) + .values(is_read=True, read_at=datetime.utcnow()) + ) + + if is_admin: + query = query.where(TicketNotification.is_for_admin == True) + else: + query = query.where( + TicketNotification.user_id == user_id, + TicketNotification.is_for_admin == False, + ) + + result = await db.execute(query) + await db.commit() + return result.rowcount + + @staticmethod + async def create_admin_notification_for_new_ticket( + db: AsyncSession, ticket: Ticket + ) -> Optional[TicketNotification]: + """Create notification for admins about new ticket.""" + from app.services.support_settings_service import SupportSettingsService + + if not SupportSettingsService.get_cabinet_admin_notifications_enabled(): + return None + + title = (ticket.title or "").strip()[:50] + message = f"Новый тикет #{ticket.id}: {title}" + + return await TicketNotificationCRUD.create( + db=db, + ticket_id=ticket.id, + user_id=ticket.user_id, + notification_type="new_ticket", + message=message, + is_for_admin=True, + ) + + @staticmethod + async def create_user_notification_for_admin_reply( + db: AsyncSession, ticket: Ticket, reply_preview: str + ) -> Optional[TicketNotification]: + """Create notification for user about admin reply.""" + from app.services.support_settings_service import SupportSettingsService + + if not SupportSettingsService.get_cabinet_user_notifications_enabled(): + return None + + preview = (reply_preview or "").strip()[:100] + message = f"Ответ на тикет #{ticket.id}: {preview}..." + + return await TicketNotificationCRUD.create( + db=db, + ticket_id=ticket.id, + user_id=ticket.user_id, + notification_type="admin_reply", + message=message, + is_for_admin=False, + ) + + @staticmethod + async def create_admin_notification_for_user_reply( + db: AsyncSession, ticket: Ticket, reply_preview: str + ) -> Optional[TicketNotification]: + """Create notification for admins about user reply.""" + from app.services.support_settings_service import SupportSettingsService + + if not SupportSettingsService.get_cabinet_admin_notifications_enabled(): + return None + + preview = (reply_preview or "").strip()[:100] + message = f"Ответ в тикете #{ticket.id}: {preview}..." + + return await TicketNotificationCRUD.create( + db=db, + ticket_id=ticket.id, + user_id=ticket.user_id, + notification_type="user_reply", + message=message, + is_for_admin=True, + ) diff --git a/app/database/models.py b/app/database/models.py index 418e6ff9..05f98a1d 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -2563,3 +2563,37 @@ class WheelSpin(Base): def __repr__(self) -> str: return f"" + + +class TicketNotification(Base): + """Уведомления о тикетах для кабинета (веб-интерфейс).""" + __tablename__ = "ticket_notifications" + __table_args__ = ( + Index("ix_ticket_notifications_user_read", "user_id", "is_read"), + Index("ix_ticket_notifications_admin_read", "is_for_admin", "is_read"), + ) + + id = Column(Integer, primary_key=True, index=True) + ticket_id = Column(Integer, ForeignKey("tickets.id", ondelete="CASCADE"), nullable=False, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + + # Тип уведомления: new_ticket, admin_reply, user_reply + notification_type = Column(String(50), nullable=False) + + # Текст уведомления + message = Column(Text, nullable=True) + + # Для админа или для пользователя + is_for_admin = Column(Boolean, default=False, nullable=False) + + # Прочитано ли уведомление + is_read = Column(Boolean, default=False, nullable=False) + + created_at = Column(DateTime, default=func.now()) + read_at = Column(DateTime, nullable=True) + + ticket = relationship("Ticket", backref="notifications") + user = relationship("User", backref="ticket_notifications") + + def __repr__(self) -> str: + return f"" diff --git a/app/services/support_settings_service.py b/app/services/support_settings_service.py index 7943b84a..8c488602 100644 --- a/app/services/support_settings_service.py +++ b/app/services/support_settings_service.py @@ -219,3 +219,32 @@ class SupportSettingsService: return cls._save() return True + # Cabinet notifications (веб-кабинет) + @classmethod + def get_cabinet_user_notifications_enabled(cls) -> bool: + """Уведомления юзерам в кабинет о ответе админа на тикет.""" + cls._load() + if "cabinet_user_notifications_enabled" in cls._data: + return bool(cls._data["cabinet_user_notifications_enabled"]) + return True # По умолчанию включено + + @classmethod + def set_cabinet_user_notifications_enabled(cls, enabled: bool) -> bool: + cls._load() + cls._data["cabinet_user_notifications_enabled"] = bool(enabled) + return cls._save() + + @classmethod + def get_cabinet_admin_notifications_enabled(cls) -> bool: + """Уведомления админам в кабинет о новых тикетах.""" + cls._load() + if "cabinet_admin_notifications_enabled" in cls._data: + return bool(cls._data["cabinet_admin_notifications_enabled"]) + return True # По умолчанию включено + + @classmethod + def set_cabinet_admin_notifications_enabled(cls, enabled: bool) -> bool: + cls._load() + cls._data["cabinet_admin_notifications_enabled"] = bool(enabled) + return cls._save() + From 346806bce03ff771e09edaad82eb8b57ac989fbb Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 19 Jan 2026 00:28:57 +0300 Subject: [PATCH 3/5] feat(notifications): integrate WebSocket notifications for ticket replies and new tickets - Added WebSocket notifications for admins on new ticket creation and user replies. - Implemented notification handling in the ticket management routes. - Enhanced error logging for notification failures. --- app/cabinet/routes/__init__.py | 8 ++++++++ app/cabinet/routes/admin_tickets.py | 12 ++++++++++++ app/cabinet/routes/tickets.py | 24 ++++++++++++++++++++++-- requirements.txt | 1 + 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/app/cabinet/routes/__init__.py b/app/cabinet/routes/__init__.py index 035ca3d2..a26fb6ba 100644 --- a/app/cabinet/routes/__init__.py +++ b/app/cabinet/routes/__init__.py @@ -32,6 +32,9 @@ from .admin_payments import router as admin_payments_router from .admin_promo_offers import router as admin_promo_offers_router from .admin_remnawave import router as admin_remnawave_router from .media import router as media_router +from .websocket import router as websocket_router +from .ticket_notifications import router as ticket_notifications_router +from .ticket_notifications import admin_router as admin_ticket_notifications_router # Main cabinet router router = APIRouter(prefix="/cabinet", tags=["Cabinet"]) @@ -71,5 +74,10 @@ router.include_router(admin_users_router) router.include_router(admin_payments_router) router.include_router(admin_promo_offers_router) router.include_router(admin_remnawave_router) +router.include_router(ticket_notifications_router) +router.include_router(admin_ticket_notifications_router) + +# WebSocket route +router.include_router(websocket_router) __all__ = ["router"] diff --git a/app/cabinet/routes/admin_tickets.py b/app/cabinet/routes/admin_tickets.py index cfef4544..0fcc2dd4 100644 --- a/app/cabinet/routes/admin_tickets.py +++ b/app/cabinet/routes/admin_tickets.py @@ -13,7 +13,9 @@ from pydantic import BaseModel, Field from app.database.models import User, Ticket, TicketMessage from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD +from app.database.crud.ticket_notification import TicketNotificationCRUD from app.config import settings +from app.cabinet.routes.websocket import notify_user_ticket_reply from ..dependencies import get_cabinet_db, get_current_admin_user from ..schemas.tickets import TicketMessageResponse @@ -348,6 +350,16 @@ async def reply_to_ticket( except Exception as e: logger.warning(f"Failed to send Telegram notification: {e}") + # Уведомить пользователя в кабинете (WebSocket) + try: + notification = await TicketNotificationCRUD.create_user_notification_for_admin_reply( + db, ticket, request.message + ) + if notification: + await notify_user_ticket_reply(ticket.user_id, ticket.id, (request.message or "")[:100]) + except Exception as e: + logger.warning(f"Failed to create cabinet notification for admin reply: {e}") + return _message_to_response(message) diff --git a/app/cabinet/routes/tickets.py b/app/cabinet/routes/tickets.py index 448c870f..500f7001 100644 --- a/app/cabinet/routes/tickets.py +++ b/app/cabinet/routes/tickets.py @@ -13,6 +13,8 @@ from sqlalchemy.orm import selectinload from app.database.models import User, Ticket, TicketMessage from app.config import settings from app.handlers.tickets import notify_admins_about_new_ticket, notify_admins_about_ticket_reply +from app.database.crud.ticket_notification import TicketNotificationCRUD +from app.cabinet.routes.websocket import notify_admins_new_ticket, notify_admins_ticket_reply from ..dependencies import get_cabinet_db, get_current_cabinet_user from ..schemas.tickets import ( @@ -162,12 +164,20 @@ async def create_ticket( # Refresh to get relationships await db.refresh(ticket, ["messages"]) - # Уведомить админов о новом тикете + # Уведомить админов о новом тикете (Telegram) try: await notify_admins_about_new_ticket(ticket, db) except Exception as e: logger.error(f"Error notifying admins about new ticket from cabinet: {e}") + # Уведомить админов в кабинете (WebSocket) + try: + notification = await TicketNotificationCRUD.create_admin_notification_for_new_ticket(db, ticket) + if notification: + await notify_admins_new_ticket(ticket.id, ticket.title, user.id) + except Exception as e: + logger.error(f"Error creating cabinet notification for new ticket: {e}") + messages = [_message_to_response(m) for m in ticket.messages] return TicketDetailResponse( @@ -275,10 +285,20 @@ async def add_ticket_message( await db.commit() await db.refresh(message) - # Уведомить админов об ответе пользователя + # Уведомить админов об ответе пользователя (Telegram) try: await notify_admins_about_ticket_reply(ticket, request.message, db) except Exception as e: logger.error(f"Error notifying admins about ticket reply from cabinet: {e}") + # Уведомить админов в кабинете (WebSocket) + try: + notification = await TicketNotificationCRUD.create_admin_notification_for_user_reply( + db, ticket, request.message + ) + if notification: + await notify_admins_ticket_reply(ticket.id, (request.message or "")[:100], user.id) + except Exception as e: + logger.error(f"Error creating cabinet notification for user reply: {e}") + return _message_to_response(message) diff --git a/requirements.txt b/requirements.txt index bc256ffb..579a18bc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,6 +14,7 @@ redis==5.0.1 PyYAML==6.0.2 fastapi==0.115.6 uvicorn==0.32.1 +websockets>=12.0 python-multipart==0.0.9 # YooKassa SDK From b1206a84c7f24e0974df57331ff616ef914bb6a9 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 19 Jan 2026 00:39:36 +0300 Subject: [PATCH 4/5] feat(notifications): enhance notification security and ownership checks - Added ownership verification for user notifications to ensure only the rightful owner can mark them as read. - Implemented checks to confirm that admin notifications are correctly identified before allowing them to be marked as read. - Introduced a new method to retrieve notifications by ID in the TicketNotificationCRUD for improved data handling. --- app/cabinet/routes/ticket_notifications.py | 28 +++++++++++++++--- app/cabinet/routes/websocket.py | 34 +++++++++++++++------- app/database/crud/ticket_notification.py | 7 +++++ 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/app/cabinet/routes/ticket_notifications.py b/app/cabinet/routes/ticket_notifications.py index 7b76685f..a8420d44 100644 --- a/app/cabinet/routes/ticket_notifications.py +++ b/app/cabinet/routes/ticket_notifications.py @@ -84,12 +84,22 @@ async def mark_notification_as_read( db: AsyncSession = Depends(get_cabinet_db), ): """Mark a notification as read.""" - success = await TicketNotificationCRUD.mark_as_read(db, notification_id) - if not success: + # Security: Verify notification belongs to current user and is not an admin notification + notification = await TicketNotificationCRUD.get_by_id(db, notification_id) + if not notification: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Notification not found", ) + + # Check ownership: notification must belong to user and not be an admin notification + if notification.user_id != user.id or notification.is_for_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to mark this notification as read", + ) + + await TicketNotificationCRUD.mark_as_read(db, notification_id) return {"success": True} @@ -154,12 +164,22 @@ async def mark_admin_notification_as_read( db: AsyncSession = Depends(get_cabinet_db), ): """Mark an admin notification as read.""" - success = await TicketNotificationCRUD.mark_as_read(db, notification_id) - if not success: + # Security: Verify notification exists and is an admin notification + notification = await TicketNotificationCRUD.get_by_id(db, notification_id) + if not notification: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Notification not found", ) + + # Check that this is actually an admin notification + if not notification.is_for_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="This is not an admin notification", + ) + + await TicketNotificationCRUD.mark_as_read(db, notification_id) return {"success": True} diff --git a/app/cabinet/routes/websocket.py b/app/cabinet/routes/websocket.py index 62f309fb..442ea9ba 100644 --- a/app/cabinet/routes/websocket.py +++ b/app/cabinet/routes/websocket.py @@ -63,7 +63,10 @@ class CabinetConnectionManager: async def send_to_user(self, user_id: int, message: dict) -> None: """Отправить сообщение конкретному пользователю.""" - connections = self._user_connections.get(user_id, set()) + # Snapshot connections under the lock to avoid mutation during iteration + async with self._lock: + connections = list(self._user_connections.get(user_id, set())) + if not connections: return @@ -78,19 +81,27 @@ class CabinetConnectionManager: disconnected.add(ws) # Cleanup disconnected - async with self._lock: - for ws in disconnected: - self._user_connections.get(user_id, set()).discard(ws) + if disconnected: + async with self._lock: + for ws in disconnected: + self._user_connections.get(user_id, set()).discard(ws) async def send_to_admins(self, message: dict) -> None: """Отправить сообщение всем админам.""" - if not self._admin_connections: - return + # Snapshot connections under the lock to avoid mutation during iteration + async with self._lock: + if not self._admin_connections: + return + # Create a snapshot: list of (user_id, list of websockets) + admin_snapshot = [ + (user_id, list(connections)) + for user_id, connections in self._admin_connections.items() + ] data = json.dumps(message, default=str, ensure_ascii=False) disconnected_by_user: Dict[int, Set[WebSocket]] = {} - for user_id, connections in self._admin_connections.items(): + for user_id, connections in admin_snapshot: for ws in connections: try: await ws.send_text(data) @@ -101,10 +112,11 @@ class CabinetConnectionManager: disconnected_by_user[user_id].add(ws) # Cleanup disconnected - async with self._lock: - for user_id, ws_set in disconnected_by_user.items(): - for ws in ws_set: - self._admin_connections.get(user_id, set()).discard(ws) + if disconnected_by_user: + async with self._lock: + for user_id, ws_set in disconnected_by_user.items(): + for ws in ws_set: + self._admin_connections.get(user_id, set()).discard(ws) # Глобальный менеджер подключений diff --git a/app/database/crud/ticket_notification.py b/app/database/crud/ticket_notification.py index fa7c1f01..3623ae68 100644 --- a/app/database/crud/ticket_notification.py +++ b/app/database/crud/ticket_notification.py @@ -17,6 +17,13 @@ logger = logging.getLogger(__name__) class TicketNotificationCRUD: """CRUD operations for ticket notifications in cabinet.""" + @staticmethod + async def get_by_id(db: AsyncSession, notification_id: int) -> Optional[TicketNotification]: + """Get notification by ID.""" + query = select(TicketNotification).where(TicketNotification.id == notification_id) + result = await db.execute(query) + return result.scalar_one_or_none() + @staticmethod async def create( db: AsyncSession, From e6e688a395e85f9a1a23d2e5bac1b44195011330 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 19 Jan 2026 01:30:31 +0300 Subject: [PATCH 5/5] fix(routes): reorder notification and ticket routers to prevent route conflicts - Moved the notifications router to be included before the tickets router to avoid conflicts. - Updated comments for clarity regarding the order of router inclusion. --- app/cabinet/routes/__init__.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/app/cabinet/routes/__init__.py b/app/cabinet/routes/__init__.py index 06369b33..bea7b005 100644 --- a/app/cabinet/routes/__init__.py +++ b/app/cabinet/routes/__init__.py @@ -44,8 +44,9 @@ router.include_router(auth_router) router.include_router(subscription_router) router.include_router(balance_router) router.include_router(referral_router) -router.include_router(tickets_router) +# Notifications router MUST be before tickets router to avoid route conflict router.include_router(ticket_notifications_router) +router.include_router(tickets_router) router.include_router(promocode_router) router.include_router(contests_router) router.include_router(polls_router) @@ -58,9 +59,9 @@ router.include_router(media_router) # Wheel routes router.include_router(wheel_router) -# Admin routes -router.include_router(admin_tickets_router) +# Admin routes (notifications router MUST be before tickets router to avoid route conflict) router.include_router(admin_ticket_notifications_router) +router.include_router(admin_tickets_router) router.include_router(admin_settings_router) router.include_router(admin_apps_router) router.include_router(admin_wheel_router) @@ -76,11 +77,6 @@ router.include_router(admin_users_router) router.include_router(admin_payments_router) router.include_router(admin_promo_offers_router) router.include_router(admin_remnawave_router) -router.include_router(ticket_notifications_router) -router.include_router(admin_ticket_notifications_router) - -# WebSocket route -router.include_router(websocket_router) # WebSocket route router.include_router(websocket_router)