Merge pull request #1495 from Fr1ngg/dev4

w
This commit is contained in:
Egor
2025-10-24 09:45:03 +03:00
committed by GitHub
10 changed files with 995 additions and 12 deletions

View File

@@ -263,3 +263,35 @@ async def get_poll_statistics(db: AsyncSession, poll_id: int) -> dict:
"reward_sum_kopeks": reward_sum,
"questions": questions,
}
async def get_poll_responses_with_answers(
db: AsyncSession,
poll_id: int,
*,
limit: int,
offset: int,
) -> tuple[list[PollResponse], int]:
total_result = await db.execute(
select(func.count()).select_from(PollResponse).where(PollResponse.poll_id == poll_id)
)
total = int(total_result.scalar_one() or 0)
if total == 0:
return [], 0
result = await db.execute(
select(PollResponse)
.options(
selectinload(PollResponse.user),
selectinload(PollResponse.answers).selectinload(PollAnswer.question),
selectinload(PollResponse.answers).selectinload(PollAnswer.option),
)
.where(PollResponse.poll_id == poll_id)
.order_by(PollResponse.sent_at.asc())
.offset(offset)
.limit(limit)
)
responses = result.scalars().unique().all()
return responses, total

View File

@@ -333,18 +333,42 @@ class TicketCRUD:
*,
limit: int = 50,
offset: int = 0,
action: Optional[str] = None,
) -> List[SupportAuditLog]:
from sqlalchemy import select, desc
result = await db.execute(
select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at)).offset(offset).limit(limit)
)
query = select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at))
if action:
query = query.where(SupportAuditLog.action == action)
result = await db.execute(query.offset(offset).limit(limit))
return result.scalars().all()
@staticmethod
async def count_support_audit(db: AsyncSession) -> int:
async def count_support_audit(db: AsyncSession, action: Optional[str] = None) -> int:
from sqlalchemy import select, func
result = await db.execute(select(func.count()).select_from(SupportAuditLog))
query = select(func.count()).select_from(SupportAuditLog)
if action:
query = query.where(SupportAuditLog.action == action)
result = await db.execute(query)
return int(result.scalar() or 0)
@staticmethod
async def list_support_audit_actions(db: AsyncSession) -> List[str]:
from sqlalchemy import select
result = await db.execute(
select(SupportAuditLog.action)
.where(SupportAuditLog.action.isnot(None))
.distinct()
.order_by(SupportAuditLog.action)
)
return [row[0] for row in result.fetchall()]
@staticmethod
async def get_open_tickets_count(db: AsyncSession) -> int:

View File

@@ -1759,26 +1759,43 @@ class MonitoringService:
return []
async def get_monitoring_logs_count(
self,
self,
db: AsyncSession,
event_type: Optional[str] = None
) -> int:
try:
from sqlalchemy import select, func
query = select(func.count(MonitoringLog.id))
if event_type:
query = query.where(MonitoringLog.event_type == event_type)
result = await db.execute(query)
count = result.scalar()
return count or 0
except Exception as e:
logger.error(f"Ошибка получения количества логов: {e}")
return 0
async def get_monitoring_event_types(self, db: AsyncSession) -> List[str]:
try:
from sqlalchemy import select
result = await db.execute(
select(MonitoringLog.event_type)
.where(MonitoringLog.event_type.isnot(None))
.distinct()
.order_by(MonitoringLog.event_type)
)
return [row[0] for row in result.fetchall() if row[0]]
except Exception as e:
logger.error(f"Ошибка получения списка типов событий мониторинга: {e}")
return []
async def cleanup_old_logs(self, db: AsyncSession, days: int = 30) -> int:
try:

View File

@@ -13,8 +13,9 @@ from .routes import (
config,
health,
main_menu_buttons,
promocodes,
miniapp,
polls,
promocodes,
promo_groups,
promo_offers,
pages,
@@ -25,6 +26,7 @@ from .routes import (
tokens,
transactions,
users,
logs,
)
@@ -69,6 +71,12 @@ OPENAPI_TAGS = [
"name": "promo-offers",
"description": "Управление промо-предложениями, шаблонами и журналом событий.",
},
{
"name": "logs",
"description": (
"Журналы мониторинга бота, действий модераторов поддержки и системный лог-файл."
),
},
{
"name": "auth",
"description": "Управление токенами доступа к административному API.",
@@ -84,6 +92,10 @@ OPENAPI_TAGS = [
"name": "miniapp",
"description": "Endpoint для Telegram Mini App с информацией о подписке пользователя.",
},
{
"name": "polls",
"description": "Создание опросов, удаление, статистика и ответы пользователей.",
},
{
"name": "pages",
"description": "Управление контентом публичных страниц: оферта, политика, FAQ и правила.",
@@ -138,5 +150,7 @@ def create_web_api_app() -> FastAPI:
app.include_router(tokens.router, prefix="/tokens", tags=["auth"])
app.include_router(remnawave.router, prefix="/remnawave", tags=["remnawave"])
app.include_router(miniapp.router, prefix="/miniapp", tags=["miniapp"])
app.include_router(polls.router, prefix="/polls", tags=["polls"])
app.include_router(logs.router, prefix="/logs", tags=["logs"])
return app

View File

@@ -3,6 +3,7 @@ from . import (
health,
main_menu_buttons,
miniapp,
polls,
promo_offers,
pages,
promo_groups,
@@ -13,6 +14,7 @@ from . import (
tokens,
transactions,
users,
logs,
)
__all__ = [
@@ -20,6 +22,7 @@ __all__ = [
"health",
"main_menu_buttons",
"miniapp",
"polls",
"promo_offers",
"pages",
"promo_groups",
@@ -30,4 +33,5 @@ __all__ = [
"tokens",
"transactions",
"users",
"logs",
]

239
app/webapi/routes/logs.py Normal file
View File

@@ -0,0 +1,239 @@
"""Маршруты административного API для просмотра логов."""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Security
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.ticket import TicketCRUD
from app.services.monitoring_service import monitoring_service
from ..dependencies import get_db_session, require_api_token
from ..schemas.logs import (
MonitoringLogEntry,
MonitoringLogListResponse,
MonitoringLogTypeListResponse,
SupportAuditActionsResponse,
SupportAuditLogEntry,
SupportAuditLogListResponse,
SystemLogPreviewResponse,
)
router = APIRouter()
logger = logging.getLogger(__name__)
SYSTEM_LOG_PREVIEW_LIMIT_DEFAULT = 4000
SYSTEM_LOG_PREVIEW_LIMIT_MAX = 20000
def _resolve_system_log_path() -> Path:
path = Path(settings.LOG_FILE)
if not path.is_absolute():
path = Path.cwd() / path
return path
async def _read_system_log(path: Path) -> tuple[str, int, Optional[float]]:
def _read() -> tuple[str, int, float]:
content = path.read_text(encoding="utf-8", errors="ignore")
stats = path.stat()
return content, stats.st_size, stats.st_mtime
return await run_in_threadpool(_read)
def _format_timestamp(timestamp: Optional[float]) -> Optional[datetime]:
if timestamp is None:
return None
return datetime.fromtimestamp(timestamp, tz=timezone.utc)
@router.get("/system", response_model=SystemLogPreviewResponse)
async def get_system_log_preview(
_: Any = Security(require_api_token),
preview_limit: int = Query(
SYSTEM_LOG_PREVIEW_LIMIT_DEFAULT,
ge=500,
le=SYSTEM_LOG_PREVIEW_LIMIT_MAX,
description="Количество символов предпросмотра от конца файла",
),
) -> SystemLogPreviewResponse:
"""Получить предпросмотр системного лог-файла бота."""
log_path = _resolve_system_log_path()
if not log_path.exists() or not log_path.is_file():
return SystemLogPreviewResponse(
path=str(log_path),
exists=False,
updated_at=None,
size_bytes=0,
size_chars=0,
preview="",
preview_chars=0,
preview_truncated=False,
download_url="/logs/system/download",
)
try:
content, size_bytes, mtime = await _read_system_log(log_path)
except FileNotFoundError:
logger.warning("Лог-файл %s исчез во время чтения", log_path)
return SystemLogPreviewResponse(
path=str(log_path),
exists=False,
updated_at=None,
size_bytes=0,
size_chars=0,
preview="",
preview_chars=0,
preview_truncated=False,
download_url="/logs/system/download",
)
except Exception as error: # pragma: no cover - защита от неожиданных ошибок чтения
logger.error("Ошибка чтения лог-файла %s: %s", log_path, error)
raise HTTPException(status_code=500, detail="Не удалось прочитать лог-файл") from error
preview_text = content[-preview_limit:] if preview_limit > 0 else ""
truncated = len(content) > len(preview_text)
return SystemLogPreviewResponse(
path=str(log_path),
exists=True,
updated_at=_format_timestamp(mtime),
size_bytes=size_bytes,
size_chars=len(content),
preview=preview_text,
preview_chars=len(preview_text),
preview_truncated=truncated,
download_url="/logs/system/download",
)
@router.get("/system/download")
async def download_system_log(
_: Any = Security(require_api_token),
) -> FileResponse:
"""Скачать полный лог-файл бота."""
log_path = _resolve_system_log_path()
if not log_path.exists() or not log_path.is_file():
raise HTTPException(status_code=404, detail="Лог-файл не найден")
try:
return FileResponse(
log_path,
media_type="text/plain",
filename=log_path.name,
)
except Exception as error: # pragma: no cover - защита от неожиданных ошибок отдачи файла
logger.error("Ошибка отправки лог-файла %s: %s", log_path, error)
raise HTTPException(status_code=500, detail="Не удалось отправить лог-файл") from error
@router.get("/monitoring", response_model=MonitoringLogListResponse)
async def list_monitoring_logs(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200, description="Количество записей на странице"),
offset: int = Query(0, ge=0, description="Смещение от начала списка"),
event_type: Optional[str] = Query(
default=None,
max_length=100,
description="Фильтр по типу события",
),
) -> MonitoringLogListResponse:
"""Получить список логов мониторинга с пагинацией."""
per_page = limit
page = (offset // per_page) + 1
raw_logs = await monitoring_service.get_monitoring_logs(
db,
event_type=event_type,
page=page,
per_page=per_page,
)
total = await monitoring_service.get_monitoring_logs_count(db, event_type=event_type)
return MonitoringLogListResponse(
total=total,
limit=limit,
offset=offset,
items=[MonitoringLogEntry(**entry) for entry in raw_logs],
)
@router.get("/monitoring/event-types", response_model=MonitoringLogTypeListResponse)
async def list_monitoring_event_types(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> MonitoringLogTypeListResponse:
"""Получить список доступных типов событий мониторинга."""
event_types = await monitoring_service.get_monitoring_event_types(db)
return MonitoringLogTypeListResponse(items=event_types)
@router.get("/support", response_model=SupportAuditLogListResponse)
async def list_support_audit_logs(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200, description="Количество записей на странице"),
offset: int = Query(0, ge=0, description="Смещение от начала списка"),
action: Optional[str] = Query(
default=None,
max_length=50,
description="Фильтр по типу действия модератора",
),
) -> SupportAuditLogListResponse:
"""Получить список аудита действий модераторов поддержки."""
logs = await TicketCRUD.list_support_audit(
db,
limit=limit,
offset=offset,
action=action,
)
total = await TicketCRUD.count_support_audit(db, action=action)
return SupportAuditLogListResponse(
total=total,
limit=limit,
offset=offset,
items=[
SupportAuditLogEntry(
id=log.id,
actor_user_id=log.actor_user_id,
actor_telegram_id=log.actor_telegram_id,
is_moderator=log.is_moderator,
action=log.action,
ticket_id=log.ticket_id,
target_user_id=log.target_user_id,
details=log.details,
created_at=log.created_at,
)
for log in logs
],
)
@router.get("/support/actions", response_model=SupportAuditActionsResponse)
async def list_support_audit_actions(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> SupportAuditActionsResponse:
"""Получить список действий, доступных в аудите поддержки."""
actions = await TicketCRUD.list_support_audit_actions(db)
return SupportAuditActionsResponse(items=actions)

308
app/webapi/routes/polls.py Normal file
View File

@@ -0,0 +1,308 @@
from __future__ import annotations
from typing import Any
from fastapi import (
APIRouter,
Depends,
HTTPException,
Query,
Response,
Security,
status,
)
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.crud.poll import (
create_poll,
delete_poll as delete_poll_record,
get_poll_by_id,
get_poll_responses_with_answers,
get_poll_statistics,
)
from app.database.models import Poll, PollAnswer, PollOption, PollQuestion, PollResponse
from ..dependencies import get_db_session, require_api_token
from ..schemas.polls import (
PollAnswerResponse,
PollCreateRequest,
PollDetailResponse,
PollListResponse,
PollOptionStats,
PollQuestionOptionResponse,
PollQuestionResponse,
PollQuestionStats,
PollResponsesListResponse,
PollStatisticsResponse,
PollSummaryResponse,
PollUserResponse,
)
router = APIRouter()
def _format_price(kopeks: int) -> float:
return round(kopeks / 100, 2)
def _serialize_option(option: PollOption) -> PollQuestionOptionResponse:
return PollQuestionOptionResponse(
id=option.id,
text=option.text,
order=option.order,
)
def _serialize_question(question: PollQuestion) -> PollQuestionResponse:
options = [
_serialize_option(option)
for option in sorted(question.options, key=lambda item: item.order)
]
return PollQuestionResponse(
id=question.id,
text=question.text,
order=question.order,
options=options,
)
def _serialize_poll_summary(
poll: Poll,
responses_count: int | None = None,
) -> PollSummaryResponse:
questions = getattr(poll, "questions", [])
if responses_count is None:
responses = getattr(poll, "responses", [])
responses_count = len(responses)
return PollSummaryResponse(
id=poll.id,
title=poll.title,
description=poll.description,
reward_enabled=poll.reward_enabled,
reward_amount_kopeks=poll.reward_amount_kopeks,
reward_amount_rubles=_format_price(poll.reward_amount_kopeks),
questions_count=len(questions),
responses_count=responses_count,
created_at=poll.created_at,
updated_at=poll.updated_at,
)
def _serialize_poll_detail(poll: Poll) -> PollDetailResponse:
questions = [
_serialize_question(question)
for question in sorted(poll.questions, key=lambda item: item.order)
]
return PollDetailResponse(
id=poll.id,
title=poll.title,
description=poll.description,
reward_enabled=poll.reward_enabled,
reward_amount_kopeks=poll.reward_amount_kopeks,
reward_amount_rubles=_format_price(poll.reward_amount_kopeks),
questions=questions,
created_at=poll.created_at,
updated_at=poll.updated_at,
)
def _serialize_answer(answer: PollAnswer) -> PollAnswerResponse:
question = getattr(answer, "question", None)
option = getattr(answer, "option", None)
return PollAnswerResponse(
question_id=question.id if question else answer.question_id,
question_text=question.text if question else None,
option_id=option.id if option else answer.option_id,
option_text=option.text if option else None,
created_at=answer.created_at,
)
def _serialize_user_response(response: PollResponse) -> PollUserResponse:
user = getattr(response, "user", None)
answers = [
_serialize_answer(answer)
for answer in sorted(response.answers, key=lambda item: item.created_at)
]
return PollUserResponse(
id=response.id,
user_id=getattr(user, "id", None),
user_telegram_id=getattr(user, "telegram_id", None),
user_username=getattr(user, "username", None),
sent_at=response.sent_at,
started_at=response.started_at,
completed_at=response.completed_at,
reward_given=response.reward_given,
reward_amount_kopeks=response.reward_amount_kopeks,
reward_amount_rubles=_format_price(response.reward_amount_kopeks),
answers=answers,
)
@router.get("", response_model=PollListResponse)
async def list_polls(
_: 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),
) -> PollListResponse:
total_result = await db.execute(select(func.count()).select_from(Poll))
total = int(total_result.scalar_one() or 0)
if total == 0:
return PollListResponse(items=[], total=0, limit=limit, offset=offset)
result = await db.execute(
select(Poll)
.options(selectinload(Poll.questions))
.order_by(Poll.created_at.desc())
.offset(offset)
.limit(limit)
)
polls = result.scalars().unique().all()
if not polls:
return PollListResponse(items=[], total=total, limit=limit, offset=offset)
poll_ids = [poll.id for poll in polls]
counts_result = await db.execute(
select(PollResponse.poll_id, func.count(PollResponse.id))
.where(PollResponse.poll_id.in_(poll_ids))
.group_by(PollResponse.poll_id)
)
responses_counts = {poll_id: count for poll_id, count in counts_result.all()}
return PollListResponse(
items=[
_serialize_poll_summary(
poll, responses_counts.get(poll.id, 0)
)
for poll in polls
],
total=total,
limit=limit,
offset=offset,
)
@router.get("/{poll_id}", response_model=PollDetailResponse)
async def get_poll(
poll_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PollDetailResponse:
poll = await get_poll_by_id(db, poll_id)
if not poll:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found")
return _serialize_poll_detail(poll)
@router.post("", response_model=PollDetailResponse, status_code=status.HTTP_201_CREATED)
async def create_poll_endpoint(
payload: PollCreateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PollDetailResponse:
poll = await create_poll(
db,
title=payload.title,
description=payload.description,
reward_enabled=payload.reward_enabled,
reward_amount_kopeks=payload.reward_amount_kopeks,
created_by=None,
questions=[
{
"text": question.text,
"options": [option.text for option in question.options],
}
for question in payload.questions
],
)
poll = await get_poll_by_id(db, poll.id)
return _serialize_poll_detail(poll)
@router.delete("/{poll_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_poll(
poll_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> Response:
success = await delete_poll_record(db, poll_id)
if not success:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found")
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/{poll_id}/stats", response_model=PollStatisticsResponse)
async def get_poll_stats(
poll_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PollStatisticsResponse:
poll = await db.get(Poll, poll_id)
if not poll:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found")
stats = await get_poll_statistics(db, poll_id)
formatted_questions = [
PollQuestionStats(
id=question_data["id"],
text=question_data["text"],
order=question_data["order"],
options=[
PollOptionStats(
id=option_data["id"],
text=option_data["text"],
count=option_data["count"],
)
for option_data in question_data.get("options", [])
],
)
for question_data in stats.get("questions", [])
]
return PollStatisticsResponse(
poll_id=poll.id,
poll_title=poll.title,
total_responses=stats.get("total_responses", 0),
completed_responses=stats.get("completed_responses", 0),
reward_sum_kopeks=stats.get("reward_sum_kopeks", 0),
reward_sum_rubles=_format_price(stats.get("reward_sum_kopeks", 0)),
questions=formatted_questions,
)
@router.get("/{poll_id}/responses", response_model=PollResponsesListResponse)
async def get_poll_responses(
poll_id: int,
_: 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),
) -> PollResponsesListResponse:
poll_exists = await db.get(Poll, poll_id)
if not poll_exists:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found")
responses, total = await get_poll_responses_with_answers(
db,
poll_id,
limit=limit,
offset=offset,
)
items = [_serialize_user_response(response) for response in responses]
return PollResponsesListResponse(
items=items,
total=total,
limit=limit,
offset=offset,
)

View File

@@ -0,0 +1,90 @@
"""Pydantic-схемы для работы с логами административного API."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
class MonitoringLogEntry(BaseModel):
"""Запись лога мониторинга."""
id: int
event_type: str = Field(..., description="Тип события мониторинга")
message: str = Field(..., description="Краткое описание события")
data: Optional[Dict[str, Any]] = Field(
default=None,
description="Дополнительные данные события",
)
is_success: bool = Field(..., description="Флаг успешности выполнения операции")
created_at: datetime = Field(..., description="Дата и время создания записи")
class MonitoringLogListResponse(BaseModel):
"""Ответ со списком логов мониторинга."""
total: int = Field(..., ge=0)
limit: int = Field(..., ge=1)
offset: int = Field(..., ge=0)
items: List[MonitoringLogEntry]
class MonitoringLogTypeListResponse(BaseModel):
"""Ответ со списком доступных типов событий мониторинга."""
items: List[str] = Field(default_factory=list)
class SupportAuditLogEntry(BaseModel):
"""Запись аудита модераторов поддержки."""
id: int
actor_user_id: Optional[int]
actor_telegram_id: int
is_moderator: bool
action: str
ticket_id: Optional[int]
target_user_id: Optional[int]
details: Optional[Dict[str, Any]] = None
created_at: datetime
class SupportAuditLogListResponse(BaseModel):
"""Ответ со списком аудита поддержки."""
total: int = Field(..., ge=0)
limit: int = Field(..., ge=1)
offset: int = Field(..., ge=0)
items: List[SupportAuditLogEntry]
class SupportAuditActionsResponse(BaseModel):
"""Ответ со списком доступных действий аудита поддержки."""
items: List[str] = Field(default_factory=list)
class SystemLogPreviewResponse(BaseModel):
"""Ответ с превью системного лог-файла бота."""
path: str = Field(..., description="Абсолютный путь до лог-файла")
exists: bool = Field(..., description="Флаг наличия лог-файла")
updated_at: Optional[datetime] = Field(
default=None,
description="Дата и время последнего изменения лог-файла",
)
size_bytes: int = Field(..., ge=0, description="Размер лог-файла в байтах")
size_chars: int = Field(..., ge=0, description="Количество символов в лог-файле")
preview: str = Field(
default="",
description="Фрагмент содержимого лог-файла, возвращаемый для предпросмотра",
)
preview_chars: int = Field(..., ge=0, description="Размер предпросмотра в символах")
preview_truncated: bool = Field(
..., description="Флаг усечения предпросмотра относительно полного файла"
)
download_url: Optional[str] = Field(
default=None,
description="Относительный путь до endpoint для скачивания лог-файла",
)

171
app/webapi/schemas/polls.py Normal file
View File

@@ -0,0 +1,171 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, field_validator, model_validator
class PollOptionCreate(BaseModel):
text: str = Field(..., min_length=1, max_length=500)
@field_validator("text")
@classmethod
def strip_text(cls, value: str) -> str:
text = value.strip()
if not text:
raise ValueError("Option text cannot be empty")
return text
class PollQuestionCreate(BaseModel):
text: str = Field(..., min_length=1, max_length=1000)
options: list[PollOptionCreate] = Field(..., min_length=2)
@field_validator("text")
@classmethod
def strip_question_text(cls, value: str) -> str:
text = value.strip()
if not text:
raise ValueError("Question text cannot be empty")
return text
@field_validator("options")
@classmethod
def validate_options(cls, value: list[PollOptionCreate]) -> list[PollOptionCreate]:
seen: set[str] = set()
for option in value:
normalized = option.text.lower()
if normalized in seen:
raise ValueError("Option texts must be unique within a question")
seen.add(normalized)
return value
class PollCreateRequest(BaseModel):
title: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = Field(default=None, max_length=4000)
reward_enabled: bool = False
reward_amount_kopeks: int = Field(default=0, ge=0, le=1_000_000_000)
questions: list[PollQuestionCreate] = Field(..., min_length=1)
@field_validator("title")
@classmethod
def strip_title(cls, value: str) -> str:
title = value.strip()
if not title:
raise ValueError("Title cannot be empty")
return title
@field_validator("description")
@classmethod
def normalize_description(cls, value: Optional[str]) -> Optional[str]:
if value is None:
return None
description = value.strip()
return description or None
@model_validator(mode="after")
def validate_reward(self) -> "PollCreateRequest":
if self.reward_enabled and self.reward_amount_kopeks <= 0:
raise ValueError("Reward amount must be positive when rewards are enabled")
if not self.reward_enabled:
self.reward_amount_kopeks = 0
return self
class PollQuestionOptionResponse(BaseModel):
id: int
text: str
order: int
class PollQuestionResponse(BaseModel):
id: int
text: str
order: int
options: list[PollQuestionOptionResponse]
class PollSummaryResponse(BaseModel):
id: int
title: str
description: Optional[str]
reward_enabled: bool
reward_amount_kopeks: int
reward_amount_rubles: float
questions_count: int
responses_count: int
created_at: datetime
updated_at: datetime
class PollDetailResponse(BaseModel):
id: int
title: str
description: Optional[str]
reward_enabled: bool
reward_amount_kopeks: int
reward_amount_rubles: float
questions: list[PollQuestionResponse]
created_at: datetime
updated_at: datetime
class PollListResponse(BaseModel):
items: list[PollSummaryResponse]
total: int
limit: int
offset: int
class PollOptionStats(BaseModel):
id: int
text: str
count: int
class PollQuestionStats(BaseModel):
id: int
text: str
order: int
options: list[PollOptionStats]
class PollStatisticsResponse(BaseModel):
poll_id: int
poll_title: str
total_responses: int
completed_responses: int
reward_sum_kopeks: int
reward_sum_rubles: float
questions: list[PollQuestionStats]
class PollAnswerResponse(BaseModel):
question_id: Optional[int]
question_text: Optional[str]
option_id: Optional[int]
option_text: Optional[str]
created_at: datetime
class PollUserResponse(BaseModel):
id: int
user_id: Optional[int]
user_telegram_id: Optional[int]
user_username: Optional[str]
sent_at: datetime
started_at: Optional[datetime]
completed_at: Optional[datetime]
reward_given: bool
reward_amount_kopeks: int
reward_amount_rubles: float
answers: list[PollAnswerResponse]
class PollResponsesListResponse(BaseModel):
items: list[PollUserResponse]
total: int
limit: int
offset: int

View File

@@ -135,10 +135,94 @@ curl -X POST "http://127.0.0.1:8080/tokens" \
| `PATCH` | `/promo-offers/templates/{id}` | Обновить текст, кнопки и параметры шаблона.
| `GET` | `/promo-offers/logs` | Журнал операций с промо-предложениями (активации, списания, выключения).
| `GET` | `/tokens` | Управление токенами доступа.
| `GET` | `/polls` | Список опросов с постраничной навигацией.
| `GET` | `/polls/{id}` | Детали опроса с вопросами и вариантами ответов.
| `POST` | `/polls` | Создать опрос: заголовок, описание, вопросы и варианты.
| `DELETE` | `/polls/{id}` | Удалить опрос целиком.
| `GET` | `/polls/{id}/stats` | Сводная статистика по ответам и начисленным наградам.
| `GET` | `/polls/{id}/responses` | Ответы пользователей с детализацией по вопросам.
| `GET` | `/logs/monitoring` | Логи мониторинга бота с пагинацией и фильтрами по типу события.
| `GET` | `/logs/monitoring/event-types` | Справочник доступных типов событий мониторинга.
| `GET` | `/logs/support` | Журнал действий модераторов поддержки (блокировки, закрытия тикетов).
| `GET` | `/logs/support/actions` | Справочник возможных действий в аудите поддержки.
| `GET` | `/logs/system` | Предпросмотр системного лог-файла бота с метаданными.
| `GET` | `/logs/system/download` | Скачивание полного лог-файла бота (`text/plain`).
> Раздел **promo-offers** в Swagger объединяет работу с персональными предложениями: выдачу скидок/бонусов пользователям, настройку
> текстов шаблонов и просмотр журнала операций (активации, автосписания, отключения просроченных акций).
### Логи бота
В административном API появился раздел **logs**. Он позволяет:
- Просматривать общие логи мониторинга (`GET /logs/monitoring`) с поддержкой пагинации (`limit`, `offset`) и фильтра по типу события (`event_type`).
- Получать справочник доступных типов событий (`GET /logs/monitoring/event-types`). Это удобно для построения фильтров во внешней админке.
- Отслеживать действия модераторов поддержки (`GET /logs/support`) с пагинацией и возможностью фильтровать по конкретному действию (`action`).
- Запрашивать список возможных действий для UI (`GET /logs/support/actions`).
- Просматривать системный лог-файл бота (`GET /logs/system`). Endpoint возвращает метаданные (путь, время изменения, размер в байтах/символах) и фрагмент конца файла, размер которого можно регулировать параметром `preview_limit` (от 500 до 20 000 символов).
- Скачивать полный системный лог в текстовом формате (`GET /logs/system/download`).
Все эндпоинты защищены токеном API и возвращают структуру с общим количеством записей, текущим `limit`/`offset` и массивом объектов. Это упрощает реализацию таблиц и постраничной навигации во внешних административных интерфейсах.
### Управление опросами
Раздел **polls** в административном API позволяет создавать и анализировать опросы, которые бот рассылает пользователям.
#### Список и детали
- `GET /polls` — возвращает массив объектов с базовой информацией: название, описание, флаги награды, количество вопросов и ответов.
- `GET /polls/{id}` — раскрывает структуру конкретного опроса, включая упорядоченные вопросы и варианты ответов. Подходит для предпросмотра перед публикацией.
#### Создание опроса
Для создания опроса отправьте JSON, соответствующий схеме:
```json
{
"title": "Оценка нового тарифа",
"description": "Помогите улучшить продукт — ответ займет до 2 минут",
"reward_enabled": true,
"reward_amount_kopeks": 1000,
"questions": [
{
"text": "Насколько вы довольны скоростью соединения?",
"options": [
{ "text": "Очень доволен" },
{ "text": "Скорее доволен" },
{ "text": "Нейтрально" },
{ "text": "Скорее недоволен" },
{ "text": "Очень недоволен" }
]
},
{
"text": "Какие улучшения вы ждёте?",
"options": [
{ "text": "Стабильность" },
{ "text": "Скорость" },
{ "text": "Поддержка" }
]
}
]
}
```
Требования валидации:
- Заголовок (`title`) — от 1 до 255 символов, не пустой после обрезки пробелов.
- Описание (`description`) — до 4000 символов, пробелы по краям удаляются.
- Если `reward_enabled=true`, сумма вознаграждения (`reward_amount_kopeks`) должна быть положительной. При `false` значение автоматически сбрасывается в `0`.
- Каждый вопрос содержит минимум два уникальных варианта ответа.
В ответ API вернёт созданный опрос с назначенными идентификаторами и полем `reward_amount_rubles`, которое удобно показывать в интерфейсе.
#### Удаление и статистика
- `DELETE /polls/{id}` — удаляет опрос и связанные с ним вопросы/ответы. Используйте с осторожностью, операция необратима.
- `GET /polls/{id}/stats` — агрегированная статистика: общее количество ответов, завершённых прохождений и сумма выданных наград. Для каждого вопроса возвращается количество выборов по вариантам.
- `GET /polls/{id}/responses` — список ответов пользователей с пагинацией (`limit`, `offset`). Каждый элемент содержит временные метки (`sent_at`, `started_at`, `completed_at`), данные пользователя (ID, username, Telegram ID), информацию о выданной награде и массив ответов с текстами вопросов/вариантов.
Такой формат позволяет без дополнительного запроса показать детализацию на фронтенде или выгрузить данные в CSV.
### RemnaWave интеграция
После включения веб-API в Swagger (`WEB_API_DOCS_ENABLED=true`) появится раздел **remnawave**. Он объединяет эндпоинты для управления панелью RemnaWave и синхронизации данных бота: