Add poll sending and ticket API enhancements

This commit is contained in:
Egor
2025-11-18 00:22:46 +03:00
parent a9f3904524
commit a02416c78b
6 changed files with 230 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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