diff --git a/app/cabinet/routes/__init__.py b/app/cabinet/routes/__init__.py index 035ca3d2..bea7b005 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"]) @@ -41,6 +44,8 @@ router.include_router(auth_router) router.include_router(subscription_router) router.include_router(balance_router) router.include_router(referral_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) @@ -54,7 +59,8 @@ router.include_router(media_router) # Wheel routes router.include_router(wheel_router) -# Admin routes +# 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) @@ -72,4 +78,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..a8420d44 --- /dev/null +++ b/app/cabinet/routes/ticket_notifications.py @@ -0,0 +1,206 @@ +"""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.""" + # 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} + + +@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.""" + # 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} + + +@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 9547b8cf..419132fc 100644 --- a/app/cabinet/routes/tickets.py +++ b/app/cabinet/routes/tickets.py @@ -12,6 +12,9 @@ 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 ( @@ -161,6 +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( @@ -268,4 +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..442ea9ba --- /dev/null +++ b/app/cabinet/routes/websocket.py @@ -0,0 +1,247 @@ +"""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: + """Отправить сообщение конкретному пользователю.""" + # 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 + + 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 + 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: + """Отправить сообщение всем админам.""" + # 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 admin_snapshot: + 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 + 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) + + +# Глобальный менеджер подключений +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..3623ae68 --- /dev/null +++ b/app/database/crud/ticket_notification.py @@ -0,0 +1,253 @@ +"""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 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, + 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() + 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