Files
remnawave-bedolaga-telegram…/app/cabinet/routes/admin_tickets.py
T
2026-01-16 02:24:46 +03:00

567 lines
19 KiB
Python

"""Admin tickets routes for cabinet."""
import logging
import math
from datetime import datetime
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from sqlalchemy.orm import selectinload
from pydantic import BaseModel, Field
from app.database.models import User, Ticket, TicketMessage
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
from app.config import settings
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.tickets import TicketMessageResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/tickets", tags=["Cabinet Admin Tickets"])
# Admin-specific schemas
class AdminTicketUserInfo(BaseModel):
"""User info for admin view."""
id: int
telegram_id: int
username: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
class Config:
from_attributes = True
class AdminTicketResponse(BaseModel):
"""Ticket data for admin."""
id: int
title: str
status: str
priority: str
created_at: datetime
updated_at: datetime
closed_at: Optional[datetime] = None
messages_count: int = 0
user: Optional[AdminTicketUserInfo] = None
last_message: Optional[TicketMessageResponse] = None
class Config:
from_attributes = True
class AdminTicketDetailResponse(BaseModel):
"""Ticket with all messages for admin."""
id: int
title: str
status: str
priority: str
created_at: datetime
updated_at: datetime
closed_at: Optional[datetime] = None
is_reply_blocked: bool = False
user: Optional[AdminTicketUserInfo] = None
messages: List[TicketMessageResponse] = []
class Config:
from_attributes = True
class AdminTicketListResponse(BaseModel):
"""Paginated ticket list for admin."""
items: List[AdminTicketResponse]
total: int
page: int
per_page: int
pages: int
class AdminReplyRequest(BaseModel):
"""Admin reply to ticket."""
message: str = Field(..., min_length=1, max_length=4000, description="Reply message")
class AdminStatusUpdateRequest(BaseModel):
"""Update ticket status."""
status: str = Field(..., description="New status: open, answered, pending, closed")
class AdminPriorityUpdateRequest(BaseModel):
"""Update ticket priority."""
priority: str = Field(..., description="New priority: low, normal, high, urgent")
class AdminStatsResponse(BaseModel):
"""Ticket statistics for admin."""
total: int
open: int
pending: int
answered: int
closed: int
class TicketSettingsResponse(BaseModel):
"""Ticket system settings."""
sla_enabled: bool
sla_minutes: int
sla_check_interval_seconds: int
sla_reminder_cooldown_minutes: int
support_system_mode: str # tickets, contact, both
class TicketSettingsUpdateRequest(BaseModel):
"""Update ticket settings."""
sla_enabled: Optional[bool] = None
sla_minutes: Optional[int] = Field(None, ge=1, le=1440, description="SLA time in minutes (1-1440)")
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")
def _message_to_response(message: TicketMessage) -> TicketMessageResponse:
"""Convert TicketMessage to response."""
return TicketMessageResponse(
id=message.id,
message_text=message.message_text or "",
is_from_admin=message.is_from_admin,
has_media=bool(message.media_file_id),
media_type=message.media_type,
media_file_id=message.media_file_id,
media_caption=message.media_caption,
created_at=message.created_at,
)
def _user_to_info(user: User) -> AdminTicketUserInfo:
"""Convert User to admin info."""
return AdminTicketUserInfo(
id=user.id,
telegram_id=user.telegram_id,
username=user.username,
first_name=user.first_name,
last_name=user.last_name,
)
def _ticket_to_admin_response(ticket: Ticket, include_messages: bool = False) -> AdminTicketResponse:
"""Convert Ticket to admin response."""
last_message = None
messages_count = len(ticket.messages) if ticket.messages else 0
if ticket.messages:
last_msg = max(ticket.messages, key=lambda m: m.created_at)
last_message = _message_to_response(last_msg)
user_info = None
if hasattr(ticket, 'user') and ticket.user:
user_info = _user_to_info(ticket.user)
return AdminTicketResponse(
id=ticket.id,
title=ticket.title or f"Ticket #{ticket.id}",
status=ticket.status,
priority=ticket.priority or "normal",
created_at=ticket.created_at,
updated_at=ticket.updated_at or ticket.created_at,
closed_at=ticket.closed_at,
messages_count=messages_count,
user=user_info,
last_message=last_message,
)
@router.get("/stats", response_model=AdminStatsResponse)
async def get_ticket_stats(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket statistics."""
# Total count
total_result = await db.execute(select(func.count()).select_from(Ticket))
total = total_result.scalar() or 0
# Count by status
statuses = {}
for status_name in ["open", "pending", "answered", "closed"]:
result = await db.execute(
select(func.count()).select_from(Ticket).where(Ticket.status == status_name)
)
statuses[status_name] = result.scalar() or 0
return AdminStatsResponse(
total=total,
open=statuses.get("open", 0),
pending=statuses.get("pending", 0),
answered=statuses.get("answered", 0),
closed=statuses.get("closed", 0),
)
@router.get("", response_model=AdminTicketListResponse)
async def get_all_tickets(
page: int = Query(1, ge=1, description="Page number"),
per_page: int = Query(20, ge=1, le=100, description="Items per page"),
status_filter: Optional[str] = Query(None, alias="status", description="Filter by status"),
priority_filter: Optional[str] = Query(None, alias="priority", description="Filter by priority"),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get all tickets for admin."""
# Base query with user relationship
query = (
select(Ticket)
.options(selectinload(Ticket.messages), selectinload(Ticket.user))
)
# Build count query
count_query = select(func.count()).select_from(Ticket)
# Apply filters
if status_filter:
query = query.where(Ticket.status == status_filter)
count_query = count_query.where(Ticket.status == status_filter)
if priority_filter:
query = query.where(Ticket.priority == priority_filter)
count_query = count_query.where(Ticket.priority == priority_filter)
# Get total count
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Paginate - order by updated_at desc (newest first)
offset = (page - 1) * per_page
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(per_page)
result = await db.execute(query)
tickets = result.scalars().all()
items = [_ticket_to_admin_response(t) for t in tickets]
pages = math.ceil(total / per_page) if total > 0 else 1
return AdminTicketListResponse(
items=items,
total=total,
page=page,
per_page=per_page,
pages=pages,
)
@router.get("/{ticket_id}", response_model=AdminTicketDetailResponse)
async def get_ticket_detail(
ticket_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket with all messages for admin."""
query = (
select(Ticket)
.where(Ticket.id == ticket_id)
.options(selectinload(Ticket.messages), selectinload(Ticket.user))
)
result = await db.execute(query)
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ticket not found",
)
messages = sorted(ticket.messages or [], key=lambda m: m.created_at)
messages_response = [_message_to_response(m) for m in messages]
user_info = None
if ticket.user:
user_info = _user_to_info(ticket.user)
return AdminTicketDetailResponse(
id=ticket.id,
title=ticket.title or f"Ticket #{ticket.id}",
status=ticket.status,
priority=ticket.priority or "normal",
created_at=ticket.created_at,
updated_at=ticket.updated_at or ticket.created_at,
closed_at=ticket.closed_at,
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, "is_reply_blocked") else False,
user=user_info,
messages=messages_response,
)
@router.post("/{ticket_id}/reply", response_model=TicketMessageResponse)
async def reply_to_ticket(
ticket_id: int,
request: AdminReplyRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reply to a ticket as admin."""
# Get ticket
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False, load_user=True)
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ticket not found",
)
# Create admin message
message = TicketMessage(
ticket_id=ticket.id,
user_id=ticket.user_id,
message_text=request.message,
is_from_admin=True,
created_at=datetime.utcnow(),
)
db.add(message)
# Update ticket status to answered
ticket.status = "answered"
ticket.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(message)
# Try to notify user via Telegram
try:
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
try:
from app.handlers.admin.tickets import notify_user_about_ticket_reply
await notify_user_about_ticket_reply(bot, ticket, request.message, db)
except Exception as e:
logger.warning(f"Failed to notify user about ticket reply: {e}")
finally:
await bot.session.close()
except Exception as e:
logger.warning(f"Failed to send Telegram notification: {e}")
return _message_to_response(message)
@router.post("/{ticket_id}/status", response_model=AdminTicketDetailResponse)
async def update_ticket_status(
ticket_id: int,
request: AdminStatusUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update ticket status."""
allowed_statuses = {"open", "pending", "answered", "closed"}
if request.status not in allowed_statuses:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid status. Allowed: {', '.join(allowed_statuses)}",
)
query = (
select(Ticket)
.where(Ticket.id == ticket_id)
.options(selectinload(Ticket.messages), selectinload(Ticket.user))
)
result = await db.execute(query)
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ticket not found",
)
ticket.status = request.status
ticket.updated_at = datetime.utcnow()
if request.status == "closed":
ticket.closed_at = datetime.utcnow()
else:
ticket.closed_at = None
await db.commit()
await db.refresh(ticket)
messages = sorted(ticket.messages or [], key=lambda m: m.created_at)
messages_response = [_message_to_response(m) for m in messages]
user_info = None
if ticket.user:
user_info = _user_to_info(ticket.user)
return AdminTicketDetailResponse(
id=ticket.id,
title=ticket.title or f"Ticket #{ticket.id}",
status=ticket.status,
priority=ticket.priority or "normal",
created_at=ticket.created_at,
updated_at=ticket.updated_at or ticket.created_at,
closed_at=ticket.closed_at,
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, "is_reply_blocked") else False,
user=user_info,
messages=messages_response,
)
@router.post("/{ticket_id}/priority", response_model=AdminTicketDetailResponse)
async def update_ticket_priority(
ticket_id: int,
request: AdminPriorityUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update ticket priority."""
allowed_priorities = {"low", "normal", "high", "urgent"}
if request.priority not in allowed_priorities:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid priority. Allowed: {', '.join(allowed_priorities)}",
)
query = (
select(Ticket)
.where(Ticket.id == ticket_id)
.options(selectinload(Ticket.messages), selectinload(Ticket.user))
)
result = await db.execute(query)
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ticket not found",
)
ticket.priority = request.priority
ticket.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(ticket)
messages = sorted(ticket.messages or [], key=lambda m: m.created_at)
messages_response = [_message_to_response(m) for m in messages]
user_info = None
if ticket.user:
user_info = _user_to_info(ticket.user)
return AdminTicketDetailResponse(
id=ticket.id,
title=ticket.title or f"Ticket #{ticket.id}",
status=ticket.status,
priority=ticket.priority or "normal",
created_at=ticket.created_at,
updated_at=ticket.updated_at or ticket.created_at,
closed_at=ticket.closed_at,
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, "is_reply_blocked") else False,
user=user_info,
messages=messages_response,
)
@router.get("/settings", response_model=TicketSettingsResponse)
async def get_ticket_settings(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket system settings."""
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(),
)
@router.patch("/settings", response_model=TicketSettingsResponse)
async def update_ticket_settings(
request: TicketSettingsUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update ticket system settings."""
import os
from pathlib import Path
# Validate support_system_mode
if request.support_system_mode is not None:
mode = request.support_system_mode.strip().lower()
if mode not in {"tickets", "contact", "both"}:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid support_system_mode. Must be: tickets, contact, or both",
)
# Update in-memory settings
if request.sla_enabled is not None:
settings.SUPPORT_TICKET_SLA_ENABLED = request.sla_enabled
if request.sla_minutes is not None:
settings.SUPPORT_TICKET_SLA_MINUTES = request.sla_minutes
if request.sla_check_interval_seconds is not None:
settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS = request.sla_check_interval_seconds
if request.sla_reminder_cooldown_minutes is not None:
settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES = request.sla_reminder_cooldown_minutes
if request.support_system_mode is not None:
settings.SUPPORT_SYSTEM_MODE = request.support_system_mode.strip().lower()
# Try to persist to .env file
try:
env_file = Path(".env")
if env_file.exists():
lines = env_file.read_text().splitlines()
updates = {}
if request.sla_enabled is not None:
updates["SUPPORT_TICKET_SLA_ENABLED"] = str(request.sla_enabled).lower()
if request.sla_minutes is not None:
updates["SUPPORT_TICKET_SLA_MINUTES"] = str(request.sla_minutes)
if request.sla_check_interval_seconds is not None:
updates["SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS"] = str(request.sla_check_interval_seconds)
if request.sla_reminder_cooldown_minutes is not None:
updates["SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES"] = str(request.sla_reminder_cooldown_minutes)
if request.support_system_mode is not None:
updates["SUPPORT_SYSTEM_MODE"] = request.support_system_mode.strip().lower()
new_lines = []
updated_keys = set()
for line in lines:
updated = False
for key, value in updates.items():
if line.startswith(f"{key}="):
new_lines.append(f"{key}={value}")
updated_keys.add(key)
updated = True
break
if not updated:
new_lines.append(line)
# Add any keys that weren't found
for key, value in updates.items():
if key not in updated_keys:
new_lines.append(f"{key}={value}")
env_file.write_text("\n".join(new_lines) + "\n")
logger.info(f"Updated ticket settings in .env file")
except Exception as e:
logger.warning(f"Failed to update .env file: {e}")
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(),
)