mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-19 19:32:10 +00:00
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.
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
186
app/cabinet/routes/ticket_notifications.py
Normal file
186
app/cabinet/routes/ticket_notifications.py
Normal file
@@ -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}
|
||||
@@ -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)
|
||||
|
||||
235
app/cabinet/routes/websocket.py
Normal file
235
app/cabinet/routes/websocket.py
Normal file
@@ -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,
|
||||
})
|
||||
246
app/database/crud/ticket_notification.py
Normal file
246
app/database/crud/ticket_notification.py
Normal file
@@ -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,
|
||||
)
|
||||
@@ -2563,3 +2563,37 @@ class WheelSpin(Base):
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<WheelSpin id={self.id} user_id={self.user_id} prize='{self.prize_display_name}'>"
|
||||
|
||||
|
||||
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"<TicketNotification id={self.id} type={self.notification_type} for_admin={self.is_for_admin}>"
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user