mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-21 03:40:55 +00:00
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
239
app/webapi/routes/logs.py
Normal 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
308
app/webapi/routes/polls.py
Normal 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,
|
||||
)
|
||||
90
app/webapi/schemas/logs.py
Normal file
90
app/webapi/schemas/logs.py
Normal 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
171
app/webapi/schemas/polls.py
Normal 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
|
||||
@@ -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 и синхронизации данных бота:
|
||||
|
||||
Reference in New Issue
Block a user