From a02416c78b2976b73c8fefe8c2d95be3357e5c07 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 18 Nov 2025 00:22:46 +0300 Subject: [PATCH] Add poll sending and ticket API enhancements --- app/webapi/routes/logs.py | 28 +++++++++++ app/webapi/routes/polls.py | 59 +++++++++++++++++++++++ app/webapi/routes/tickets.py | 88 ++++++++++++++++++++++++++++++++++- app/webapi/schemas/logs.py | 11 +++++ app/webapi/schemas/polls.py | 20 ++++++++ app/webapi/schemas/tickets.py | 25 ++++++++++ 6 files changed, 230 insertions(+), 1 deletion(-) diff --git a/app/webapi/routes/logs.py b/app/webapi/routes/logs.py index 81d07524..4e275348 100644 --- a/app/webapi/routes/logs.py +++ b/app/webapi/routes/logs.py @@ -24,6 +24,7 @@ from ..schemas.logs import ( SupportAuditLogEntry, SupportAuditLogListResponse, SystemLogPreviewResponse, + SystemLogFullResponse, ) router = APIRouter() @@ -141,6 +142,33 @@ async def download_system_log( raise HTTPException(status_code=500, detail="Не удалось отправить лог-файл") from error +@router.get("/system/full", response_model=SystemLogFullResponse) +async def get_system_log_full( + _: Any = Security(require_api_token), +) -> SystemLogFullResponse: + """Получить полный системный лог-файл бота.""" + + log_path = _resolve_system_log_path() + + if not log_path.exists() or not log_path.is_file(): + raise HTTPException(status_code=404, detail="Лог-файл не найден") + + try: + content, size_bytes, mtime = await _read_system_log(log_path) + except Exception as error: # pragma: no cover - защита от неожиданных ошибок чтения + logger.error("Ошибка чтения лог-файла %s: %s", log_path, error) + raise HTTPException(status_code=500, detail="Не удалось прочитать лог-файл") from error + + return SystemLogFullResponse( + path=str(log_path), + exists=True, + updated_at=_format_timestamp(mtime), + size_bytes=size_bytes, + size_chars=len(content), + content=content, + ) + + @router.get("/monitoring", response_model=MonitoringLogListResponse) async def list_monitoring_logs( _: Any = Security(require_api_token), diff --git a/app/webapi/routes/polls.py b/app/webapi/routes/polls.py index 8442cc81..68fc278e 100644 --- a/app/webapi/routes/polls.py +++ b/app/webapi/routes/polls.py @@ -2,6 +2,9 @@ from __future__ import annotations from typing import Any +from aiogram import Bot +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode from fastapi import ( APIRouter, Depends, @@ -23,6 +26,8 @@ from app.database.crud.poll import ( get_poll_statistics, ) from app.database.models import Poll, PollAnswer, PollOption, PollQuestion, PollResponse +from app.handlers.admin.messages import get_custom_users, get_target_users +from app.services.poll_service import send_poll_to_users from ..dependencies import get_db_session, require_api_token from ..schemas.polls import ( @@ -38,6 +43,8 @@ from ..schemas.polls import ( PollStatisticsResponse, PollSummaryResponse, PollUserResponse, + PollSendRequest, + PollSendResponse, ) router = APIRouter() @@ -306,3 +313,55 @@ async def get_poll_responses( limit=limit, offset=offset, ) + + +@router.post("/{poll_id}/send", response_model=PollSendResponse) +async def send_poll( + poll_id: int, + payload: PollSendRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> PollSendResponse: + poll = await get_poll_by_id(db, poll_id) + if not poll: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found") + + target = payload.target.strip() + if not target: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Target must not be empty") + + if target.startswith("custom_"): + users = await get_custom_users(db, target.replace("custom_", "")) + else: + users = await get_target_users(db, target) + + if not users: + return PollSendResponse( + poll_id=poll_id, + target=target, + sent=0, + failed=0, + skipped=0, + total=0, + ) + + from app.config import settings + + bot = Bot( + token=settings.BOT_TOKEN, + default=DefaultBotProperties(parse_mode=ParseMode.HTML), + ) + + try: + result = await send_poll_to_users(bot, db, poll, users) + finally: + await bot.session.close() + + return PollSendResponse( + poll_id=poll_id, + target=target, + sent=result.get("sent", 0), + failed=result.get("failed", 0), + skipped=result.get("skipped", 0), + total=result.get("total", 0), + ) diff --git a/app/webapi/routes/tickets.py b/app/webapi/routes/tickets.py index ec274bd3..325f77f8 100644 --- a/app/webapi/routes/tickets.py +++ b/app/webapi/routes/tickets.py @@ -6,7 +6,12 @@ from typing import Any, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Security, status from sqlalchemy.ext.asyncio import AsyncSession -from app.database.crud.ticket import TicketCRUD +from aiogram import Bot +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode + +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 @@ -14,7 +19,10 @@ from ..schemas.tickets import ( TicketMessageResponse, TicketPriorityUpdateRequest, TicketReplyBlockRequest, + TicketReplyRequest, + TicketReplyResponse, TicketResponse, + TicketMediaResponse, TicketStatusUpdateRequest, ) @@ -29,6 +37,7 @@ def _serialize_message(message: TicketMessage) -> TicketMessageResponse: 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, ) @@ -183,3 +192,80 @@ async def clear_reply_block( 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) + + 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, + _: 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") + + 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, + ) diff --git a/app/webapi/schemas/logs.py b/app/webapi/schemas/logs.py index c21858a5..1ad281e5 100644 --- a/app/webapi/schemas/logs.py +++ b/app/webapi/schemas/logs.py @@ -88,3 +88,14 @@ class SystemLogPreviewResponse(BaseModel): default=None, description="Относительный путь до endpoint для скачивания лог-файла", ) + + +class SystemLogFullResponse(BaseModel): + """Полное содержимое системного лог-файла.""" + + path: str + exists: bool + updated_at: Optional[datetime] = None + size_bytes: int + size_chars: int + content: str diff --git a/app/webapi/schemas/polls.py b/app/webapi/schemas/polls.py index 3d95a715..c6b7bcf5 100644 --- a/app/webapi/schemas/polls.py +++ b/app/webapi/schemas/polls.py @@ -169,3 +169,23 @@ class PollResponsesListResponse(BaseModel): total: int limit: int offset: int + + +class PollSendRequest(BaseModel): + target: str = Field( + ..., + description=( + "Аудитория для отправки опроса (например: all, active, trial, " + "custom_today и т.д.)" + ), + max_length=100, + ) + + +class PollSendResponse(BaseModel): + poll_id: int + target: str + sent: int + failed: int + skipped: int + total: int diff --git a/app/webapi/schemas/tickets.py b/app/webapi/schemas/tickets.py index 7334bbb8..129fdc52 100644 --- a/app/webapi/schemas/tickets.py +++ b/app/webapi/schemas/tickets.py @@ -13,6 +13,7 @@ class TicketMessageResponse(BaseModel): is_from_admin: bool has_media: bool media_type: Optional[str] = None + media_file_id: Optional[str] = None media_caption: Optional[str] = None created_at: datetime @@ -42,3 +43,27 @@ class TicketPriorityUpdateRequest(BaseModel): class TicketReplyBlockRequest(BaseModel): permanent: bool = False until: Optional[datetime] = None + + +class TicketReplyRequest(BaseModel): + message_text: Optional[str] = Field(default=None, max_length=4000) + media_type: Optional[str] = Field( + default=None, + description="Тип медиа (photo, video, document, voice и т.д.)", + max_length=32, + ) + media_file_id: Optional[str] = Field(default=None, max_length=255) + media_caption: Optional[str] = Field(default=None, max_length=4000) + + +class TicketReplyResponse(BaseModel): + ticket: TicketResponse + message: TicketMessageResponse + + +class TicketMediaResponse(BaseModel): + id: int + ticket_id: int + media_type: str + media_file_id: str + media_caption: Optional[str] = None