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:
PEDZEO
2026-01-19 00:02:41 +03:00
parent c63db708cc
commit 67c3dba1cc
8 changed files with 794 additions and 2 deletions

View File

@@ -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"]

View File

@@ -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(),
)

View 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}

View File

@@ -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)

View 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,
})

View 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,
)

View File

@@ -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}>"

View File

@@ -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()