eb18994b7d
- 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
312 lines
11 KiB
Python
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,
|
|
)
|