Files
remnawave-bedolaga-telegram…/app/webapi/routes/tickets.py
T
Fringg eb18994b7d fix: complete datetime.utcnow() → datetime.now(UTC) migration
- Migrate 660+ datetime.utcnow() across 153 files to datetime.now(UTC)
- Migrate 30+ datetime.now() without UTC to datetime.now(UTC)
- Convert all 170 DateTime columns to DateTime(timezone=True)
- Add migrate_datetime_to_timestamptz() in universal_migration with SET LOCAL timezone='UTC' safety
- Remove 70+ .replace(tzinfo=None) workarounds
- Fix utcfromtimestamp → fromtimestamp(..., tz=UTC)
- Fix fromtimestamp() without tz= (system_logs, backup_service, referral_diagnostics)
- Fix fromisoformat/isoparse to ensure aware output (platega, yookassa, wata, miniapp, nalogo)
- Fix strptime() to add .replace(tzinfo=UTC) (backup_service, referral_diagnostics)
- Fix datetime.combine() to include tzinfo=UTC (remnawave_sync, traffic_monitoring)
- Fix datetime.max/datetime.min sentinels with .replace(tzinfo=UTC)
- Rename panel_datetime_to_naive_utc → panel_datetime_to_utc
- Remove DTZ003 from ruff ignore list
2026-02-17 04:45:40 +03:00

312 lines
11 KiB
Python

from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Security, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
from app.database.models import Ticket, TicketMessage, TicketStatus
from ..dependencies import get_db_session, require_api_token
from ..schemas.tickets import (
TicketMediaResponse,
TicketMessageResponse,
TicketPriorityUpdateRequest,
TicketReplyBlockRequest,
TicketReplyRequest,
TicketReplyResponse,
TicketResponse,
TicketStatusUpdateRequest,
)
router = APIRouter()
logger = structlog.get_logger(__name__)
def _serialize_message(message: TicketMessage) -> TicketMessageResponse:
return TicketMessageResponse(
id=message.id,
user_id=message.user_id,
message_text=message.message_text,
is_from_admin=message.is_from_admin,
has_media=message.has_media,
media_type=message.media_type,
media_file_id=message.media_file_id,
media_caption=message.media_caption,
created_at=message.created_at,
)
def _serialize_ticket(ticket: Ticket, include_messages: bool = False) -> TicketResponse:
messages = []
if include_messages:
messages = sorted(ticket.messages, key=lambda m: m.created_at)
return TicketResponse(
id=ticket.id,
user_id=ticket.user_id,
title=ticket.title,
status=ticket.status,
priority=ticket.priority,
created_at=ticket.created_at,
updated_at=ticket.updated_at,
closed_at=ticket.closed_at,
user_reply_block_permanent=ticket.user_reply_block_permanent,
user_reply_block_until=ticket.user_reply_block_until,
messages=[_serialize_message(message) for message in messages],
)
@router.get('', response_model=list[TicketResponse])
async def list_tickets(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
status_filter: TicketStatus | None = Query(default=None, alias='status'),
priority: str | None = Query(default=None),
user_id: int | None = Query(default=None),
) -> list[TicketResponse]:
status_value = status_filter.value if status_filter else None
if user_id:
tickets = await TicketCRUD.get_user_tickets(
db,
user_id=user_id,
status=status_value,
limit=limit,
offset=offset,
)
else:
tickets = await TicketCRUD.get_all_tickets(
db,
status=status_value,
priority=priority,
limit=limit,
offset=offset,
)
return [_serialize_ticket(ticket) for ticket in tickets]
@router.get('/{ticket_id}', response_model=TicketResponse)
async def get_ticket(
ticket_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> TicketResponse:
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
if not ticket:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Ticket not found')
return _serialize_ticket(ticket, include_messages=True)
@router.post('/{ticket_id}/status', response_model=TicketResponse)
async def update_ticket_status(
ticket_id: int,
payload: TicketStatusUpdateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> TicketResponse:
try:
status_value = TicketStatus(payload.status).value
except ValueError as error:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Invalid ticket status') from error
closed_at = datetime.now(UTC) if status_value == TicketStatus.CLOSED.value else None
success = await TicketCRUD.update_ticket_status(db, ticket_id, status_value, closed_at)
if not success:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Ticket not found')
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
return _serialize_ticket(ticket, include_messages=True)
@router.post('/{ticket_id}/priority', response_model=TicketResponse)
async def update_ticket_priority(
ticket_id: int,
payload: TicketPriorityUpdateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> TicketResponse:
allowed_priorities = {'low', 'normal', 'high', 'urgent'}
if payload.priority not in allowed_priorities:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Invalid priority')
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
if not ticket:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Ticket not found')
ticket.priority = payload.priority
ticket.updated_at = datetime.now(UTC)
await db.commit()
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
return _serialize_ticket(ticket, include_messages=True)
@router.post('/{ticket_id}/reply-block', response_model=TicketResponse)
async def update_reply_block(
ticket_id: int,
payload: TicketReplyBlockRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> TicketResponse:
until = payload.until
if not payload.permanent and until and until <= datetime.now(UTC):
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Block expiration must be in the future')
success = await TicketCRUD.set_user_reply_block(
db,
ticket_id,
permanent=payload.permanent,
until=until,
)
if not success:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Ticket not found')
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
return _serialize_ticket(ticket, include_messages=True)
@router.delete('/{ticket_id}/reply-block', response_model=TicketResponse)
async def clear_reply_block(
ticket_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> TicketResponse:
success = await TicketCRUD.set_user_reply_block(
db,
ticket_id,
permanent=False,
until=None,
)
if not success:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Ticket not found')
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
return _serialize_ticket(ticket, include_messages=True)
@router.post('/{ticket_id}/reply', response_model=TicketReplyResponse, status_code=status.HTTP_201_CREATED)
async def reply_to_ticket(
ticket_id: int,
payload: TicketReplyRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> TicketReplyResponse:
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False, load_user=True)
if not ticket:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Ticket not found')
message_text = (payload.message_text or '').strip()
if not message_text and not payload.media_file_id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Message text or media is required')
final_message_text = message_text or (payload.media_caption or '').strip() or '[media]'
message = await TicketMessageCRUD.add_message(
db,
ticket_id=ticket_id,
user_id=ticket.user_id,
message_text=final_message_text,
is_from_admin=True,
media_type=payload.media_type,
media_file_id=payload.media_file_id,
media_caption=payload.media_caption,
)
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, final_message_text, db)
finally:
await bot.session.close()
ticket_with_messages = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
# Отправляем событие о новом сообщении через API
try:
from app.services.event_emitter import event_emitter
await event_emitter.emit(
'ticket.message_added',
{
'ticket_id': ticket_id,
'message_id': message.id,
'user_id': ticket.user_id,
'is_from_admin': True,
'message_text': final_message_text[:200],
'has_media': bool(payload.media_file_id),
'status': ticket_with_messages.status,
},
db=db,
)
except Exception as error:
logger.warning('Failed to emit ticket.message_added event', error=error)
return TicketReplyResponse(
ticket=_serialize_ticket(ticket_with_messages, include_messages=True),
message=_serialize_message(message),
)
@router.get(
'/{ticket_id}/messages/{message_id}/media',
response_model=TicketMediaResponse,
)
async def get_ticket_message_media(
ticket_id: int,
message_id: int,
request: Request,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> TicketMediaResponse:
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
if not ticket:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Ticket not found')
message = next((m for m in ticket.messages if m.id == message_id), None)
if not message:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Message not found')
if not message.has_media or not message.media_file_id or not message.media_type:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Media not found for this message')
media_url: str | None = None
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
try:
file = await bot.get_file(message.media_file_id)
if file.file_path:
media_url = str(request.url_for('download_media', file_id=message.media_file_id))
except Exception as error:
logger.warning(
'Failed to resolve media URL for ticket message', ticket_id=ticket_id, message_id=message_id, error=error
)
finally:
await bot.session.close()
return TicketMediaResponse(
id=message.id,
ticket_id=ticket.id,
media_type=message.media_type,
media_file_id=message.media_file_id,
media_caption=message.media_caption,
media_url=media_url,
)