Логи стали видны из админки, а не только через SQL
Раздел «Логи» читает analytics.events из ClickHouse: фильтры по типу события, пользователю (ID или username) и датам, по 50 строк на страницу, время в часовом поясе админки. Payload раскрывается как JSON, ID ведёт в карточку игрока. Чтение идёт отдельным ленивым клиентом: если ClickHouse лежит, страница сообщает об этом и не роняет админку. Боту для этого ничего не меняли.
This commit is contained in:
parent
92791f1ba9
commit
107a79d05e
|
|
@ -79,6 +79,11 @@ HttpOnly-cookie (`ADMIN_SESSION_SECRET`), все формы защищены CSR
|
|||
журнал `money` с действием «Корректировка админом»). Каждое ручное изменение пишется в
|
||||
`bot.user_changes`: кто, когда, было → стало и причина.
|
||||
|
||||
Раздел «Логи» — события из ClickHouse: фильтры по типу, пользователю (ID или username)
|
||||
и датам, пагинация по 50. Время показывается в `TIMEZONE`, payload раскрывается как JSON,
|
||||
ID пользователя ведёт в его карточку. ClickHouse читается отдельным ленивым клиентом;
|
||||
если он недоступен, страница показывает ошибку, а не падает.
|
||||
|
||||
## Аналитика (ClickHouse)
|
||||
|
||||
Все значимые действия бота пишутся в ClickHouse в единую таблицу `analytics.events`.
|
||||
|
|
@ -140,7 +145,7 @@ make backup E=test # на домашнем сервере: ./scripts/clickho
|
|||
|
||||
## Версионирование
|
||||
|
||||
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.7`.
|
||||
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.8`.
|
||||
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
|
||||
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
||||
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
|
||||
|
|
|
|||
|
|
@ -13,8 +13,9 @@ from fastapi.templating import Jinja2Templates
|
|||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from admin.middleware import AdminAuthMiddleware
|
||||
from admin.routes import auth, health, messages, pages, schedules, users
|
||||
from admin.routes import auth, health, logs, messages, pages, schedules, users
|
||||
from common import __version__
|
||||
from common.analytics import read as analytics_read
|
||||
from common.config import AdminSettings, get_admin_settings
|
||||
from common.db.pool import close_pool, init_pool
|
||||
from common.logging_config import setup_logging
|
||||
|
|
@ -53,6 +54,7 @@ def create_app(settings: AdminSettings | None = None, *, init_db: bool = True) -
|
|||
finally:
|
||||
if init_db:
|
||||
await close_pool()
|
||||
await analytics_read.close_client()
|
||||
|
||||
app = FastAPI(
|
||||
title="DispelEvilBot Admin",
|
||||
|
|
@ -88,4 +90,5 @@ def create_app(settings: AdminSettings | None = None, *, init_db: bool = True) -
|
|||
app.include_router(messages.router)
|
||||
app.include_router(schedules.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(logs.router)
|
||||
return app
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
"""Раздел «Логи»: события из ClickHouse с фильтрами и пагинацией."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from common import __version__
|
||||
from common.analytics import ALL_EVENT_TYPES
|
||||
from common.analytics import read as analytics_read
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/logs", tags=["logs"])
|
||||
|
||||
#: Сколько событий показывать на странице.
|
||||
PAGE_SIZE = 50
|
||||
|
||||
|
||||
def _pretty_payload(payload: str) -> str:
|
||||
"""Отформатировать JSON-payload для просмотра; битый JSON — как есть."""
|
||||
try:
|
||||
return json.dumps(json.loads(payload), ensure_ascii=False, indent=2)
|
||||
except (TypeError, ValueError):
|
||||
return payload or ""
|
||||
|
||||
|
||||
def _decorate(row: dict[str, Any], settings: Any) -> dict[str, Any]:
|
||||
"""Добавить локальное время и читаемый payload."""
|
||||
moment = row.get("event_time")
|
||||
if isinstance(moment, datetime):
|
||||
if moment.tzinfo is None:
|
||||
moment = moment.replace(tzinfo=UTC)
|
||||
time_local = moment.astimezone(settings.tzinfo).strftime("%d.%m.%Y %H:%M:%S")
|
||||
else:
|
||||
time_local = "—"
|
||||
|
||||
return {
|
||||
**row,
|
||||
"time_local": time_local,
|
||||
"payload_pretty": _pretty_payload(str(row.get("payload") or "")),
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def logs_page(
|
||||
request: Request,
|
||||
event_type: str = "",
|
||||
user: str = "",
|
||||
date_from: str = "",
|
||||
date_to: str = "",
|
||||
page: int = 1,
|
||||
) -> HTMLResponse:
|
||||
"""Показать события с фильтрами по типу, пользователю и датам."""
|
||||
settings = request.app.state.settings
|
||||
today = datetime.now(settings.tzinfo).date()
|
||||
date_from = date_from or (today - timedelta(days=6)).isoformat()
|
||||
date_to = date_to or today.isoformat()
|
||||
page = max(page, 1)
|
||||
|
||||
events: list[dict[str, Any]] = []
|
||||
total = 0
|
||||
error: str | None = None
|
||||
|
||||
try:
|
||||
events, total = await analytics_read.fetch_events(
|
||||
settings,
|
||||
event_type=event_type,
|
||||
user=user,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
limit=PAGE_SIZE,
|
||||
offset=(page - 1) * PAGE_SIZE,
|
||||
)
|
||||
except ValueError:
|
||||
error = "Даты должны быть в формате ГГГГ-ММ-ДД."
|
||||
except Exception:
|
||||
logger.exception("Не удалось прочитать события из ClickHouse")
|
||||
error = "ClickHouse недоступен — логи временно не читаются."
|
||||
|
||||
pages = max((total + PAGE_SIZE - 1) // PAGE_SIZE, 1)
|
||||
query_base = urlencode(
|
||||
{
|
||||
key: value
|
||||
for key, value in {
|
||||
"event_type": event_type,
|
||||
"user": user,
|
||||
"date_from": date_from,
|
||||
"date_to": date_to,
|
||||
}.items()
|
||||
if value
|
||||
}
|
||||
)
|
||||
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="logs/list.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"events": [_decorate(row, settings) for row in events],
|
||||
"event_types": sorted(ALL_EVENT_TYPES),
|
||||
"event_type": event_type,
|
||||
"user": user,
|
||||
"date_from": date_from,
|
||||
"date_to": date_to,
|
||||
"page": page,
|
||||
"pages": pages,
|
||||
"total": total,
|
||||
"error": error,
|
||||
"query_base": query_base,
|
||||
},
|
||||
)
|
||||
|
|
@ -29,7 +29,7 @@ SECTIONS: tuple[dict[str, str | None], ...] = (
|
|||
{
|
||||
"title": "Логи",
|
||||
"description": "События из ClickHouse с фильтрами",
|
||||
"url": None,
|
||||
"url": "/logs",
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
<a href="/messages" class="text-slate-600 hover:text-slate-900">Сообщения</a>
|
||||
<a href="/schedules" class="text-slate-600 hover:text-slate-900">Расписания</a>
|
||||
<a href="/users" class="text-slate-600 hover:text-slate-900">Пользователи</a>
|
||||
<a href="/logs" class="text-slate-600 hover:text-slate-900">Логи</a>
|
||||
{% if request.session.get('admin_id') %}
|
||||
<span class="text-slate-500">{{ request.session.get('admin_username') or 'админ' }}</span>
|
||||
<form method="post" action="/logout">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Логи{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">Логи</h1>
|
||||
<p class="mt-1 text-sm text-slate-600">События из ClickHouse · найдено: {{ total }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="get" action="/logs" class="mt-4 flex flex-wrap items-end gap-3 rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div>
|
||||
<label for="event_type" class="text-xs text-slate-500">Тип</label>
|
||||
<select
|
||||
id="event_type"
|
||||
name="event_type"
|
||||
class="mt-1 block rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Все типы</option>
|
||||
{% for type in event_types %}
|
||||
<option value="{{ type }}" {% if type == event_type %}selected{% endif %}>{{ type }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="user" class="text-xs text-slate-500">Пользователь</label>
|
||||
<input
|
||||
id="user"
|
||||
name="user"
|
||||
type="text"
|
||||
value="{{ user }}"
|
||||
placeholder="ID или @username"
|
||||
class="mt-1 block w-48 rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="date_from" class="text-xs text-slate-500">С</label>
|
||||
<input
|
||||
id="date_from"
|
||||
name="date_from"
|
||||
type="date"
|
||||
value="{{ date_from }}"
|
||||
class="mt-1 block rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="date_to" class="text-xs text-slate-500">По</label>
|
||||
<input
|
||||
id="date_to"
|
||||
name="date_to"
|
||||
type="date"
|
||||
value="{{ date_to }}"
|
||||
class="mt-1 block rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
||||
Показать
|
||||
</button>
|
||||
<a href="/logs" class="text-sm text-slate-600 hover:underline">Сбросить</a>
|
||||
</form>
|
||||
|
||||
{% if error %}
|
||||
<div class="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-slate-600">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Время</th>
|
||||
<th class="px-4 py-3 font-medium">Событие</th>
|
||||
<th class="px-4 py-3 font-medium">Пользователь</th>
|
||||
<th class="px-4 py-3 font-medium">Чат</th>
|
||||
<th class="px-4 py-3 font-medium">Версия</th>
|
||||
<th class="px-4 py-3 font-medium">Payload</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for event in events %}
|
||||
<tr class="align-top hover:bg-slate-50">
|
||||
<td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ event.time_local }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="rounded bg-slate-100 px-2 py-0.5 font-mono text-xs">{{ event.event_type }}</span>
|
||||
{% if event.prod %}<span class="ml-1 rounded bg-indigo-100 px-2 py-0.5 text-xs text-indigo-800">prod</span>{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if event.user_id %}
|
||||
{% if event.username %}<div>@{{ event.username }}</div>{% endif %}
|
||||
<a href="/users/{{ event.user_id }}" class="font-mono text-xs text-sky-700 hover:underline">{{ event.user_id }}</a>
|
||||
{% else %}
|
||||
<span class="text-slate-400">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-slate-500">{{ event.chat_id or "—" }}</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-500">{{ event.bot_version }}</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if event.payload_pretty %}
|
||||
<details>
|
||||
<summary class="cursor-pointer text-xs text-slate-500">JSON</summary>
|
||||
<pre class="mt-1 max-h-64 max-w-xl overflow-auto rounded bg-slate-50 p-2 text-xs">{{ event.payload_pretty }}</pre>
|
||||
</details>
|
||||
{% else %}
|
||||
<span class="text-slate-400">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-6 text-center text-slate-500">
|
||||
{% if error %}Данные не загрузились.{% else %}За выбранный период событий нет.{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if pages > 1 %}
|
||||
<div class="mt-4 flex items-center justify-between text-sm text-slate-600">
|
||||
<div>Страница {{ page }} из {{ pages }}</div>
|
||||
<div class="flex gap-3">
|
||||
{% if page > 1 %}
|
||||
<a href="/logs?{{ query_base }}&page={{ page - 1 }}" class="hover:underline">← Назад</a>
|
||||
{% endif %}
|
||||
{% if page < pages %}
|
||||
<a href="/logs?{{ query_base }}&page={{ page + 1 }}" class="hover:underline">Вперёд →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
"""Чтение событий ClickHouse для админки (Блок 4.3).
|
||||
|
||||
Клиент создаётся лениво и только на чтение; недоступность ClickHouse не роняет
|
||||
админку — страница покажет ошибку. Запись событий живёт в ``client.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime, time, timedelta
|
||||
from typing import Any
|
||||
|
||||
from common.analytics.events import ALL_EVENT_TYPES
|
||||
from common.config import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Колонки, которые показываем в логах.
|
||||
EVENT_FIELDS = (
|
||||
"event_time",
|
||||
"event_type",
|
||||
"user_id",
|
||||
"username",
|
||||
"chat_id",
|
||||
"payload",
|
||||
"bot_version",
|
||||
"prod",
|
||||
)
|
||||
|
||||
_client: Any | None = None
|
||||
|
||||
|
||||
async def _ensure_client(settings: Settings) -> Any:
|
||||
"""Создать (один раз) асинхронный клиент ClickHouse."""
|
||||
global _client
|
||||
if _client is not None:
|
||||
return _client
|
||||
|
||||
import clickhouse_connect
|
||||
|
||||
_client = await clickhouse_connect.get_async_client(
|
||||
host=settings.clickhouse_host,
|
||||
port=settings.clickhouse_port,
|
||||
username=settings.clickhouse_user,
|
||||
password=settings.clickhouse_password,
|
||||
database=settings.clickhouse_db,
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
async def close_client() -> None:
|
||||
"""Закрыть клиент при остановке админки."""
|
||||
global _client
|
||||
if _client is not None:
|
||||
client, _client = _client, None
|
||||
try:
|
||||
await client.close()
|
||||
except Exception:
|
||||
logger.warning("Не удалось закрыть клиент ClickHouse", exc_info=True)
|
||||
|
||||
|
||||
def _where(
|
||||
*,
|
||||
event_type: str,
|
||||
user: str,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
clauses = ["event_time >= {start:DateTime}", "event_time < {end:DateTime}"]
|
||||
params: dict[str, Any] = {"start": start, "end": end}
|
||||
|
||||
if event_type and event_type in ALL_EVENT_TYPES:
|
||||
clauses.append("event_type = {event_type:String}")
|
||||
params["event_type"] = event_type
|
||||
|
||||
user = user.strip()
|
||||
if user:
|
||||
if user.isdigit():
|
||||
clauses.append("user_id = {user_id:Int64}")
|
||||
params["user_id"] = int(user)
|
||||
else:
|
||||
clauses.append("positionCaseInsensitive(username, {user_text:String}) > 0")
|
||||
params["user_text"] = user
|
||||
|
||||
return " WHERE " + " AND ".join(clauses), params
|
||||
|
||||
|
||||
async def fetch_events(
|
||||
settings: Settings,
|
||||
*,
|
||||
event_type: str = "",
|
||||
user: str = "",
|
||||
date_from: str = "",
|
||||
date_to: str = "",
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""Вернуть события и общее количество по фильтрам.
|
||||
|
||||
Даты — ``YYYY-MM-DD`` в таймзоне админки; внутри переводятся в UTC.
|
||||
"""
|
||||
today = datetime.now(settings.tzinfo).date()
|
||||
start_date = datetime.strptime(date_from, "%Y-%m-%d").date() if date_from else today - timedelta(days=6)
|
||||
end_date = datetime.strptime(date_to, "%Y-%m-%d").date() if date_to else today
|
||||
|
||||
start = datetime.combine(start_date, time.min, tzinfo=settings.tzinfo).astimezone(UTC).replace(tzinfo=None)
|
||||
end = (
|
||||
datetime.combine(end_date + timedelta(days=1), time.min, tzinfo=settings.tzinfo)
|
||||
.astimezone(UTC)
|
||||
.replace(tzinfo=None)
|
||||
)
|
||||
|
||||
where, params = _where(event_type=event_type, user=user, start=start, end=end)
|
||||
client = await _ensure_client(settings)
|
||||
|
||||
count_result = await client.query(f"SELECT count() FROM analytics.events{where}", parameters=params)
|
||||
total = int(count_result.result_rows[0][0]) if count_result.result_rows else 0
|
||||
|
||||
result = await client.query(
|
||||
f"SELECT {', '.join(EVENT_FIELDS)} FROM analytics.events{where}"
|
||||
" ORDER BY event_time DESC LIMIT {limit:UInt32} OFFSET {offset:UInt32}",
|
||||
parameters={**params, "limit": limit, "offset": offset},
|
||||
)
|
||||
rows = [dict(zip(result.column_names, row, strict=False)) for row in result.result_rows]
|
||||
return rows, total
|
||||
|
|
@ -7,4 +7,4 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
__version__ = "0.4.7"
|
||||
__version__ = "0.4.8"
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ services:
|
|||
environment:
|
||||
DB_HOST: postgres
|
||||
DB_PORT: "5432"
|
||||
CLICKHOUSE_HOST: clickhouse
|
||||
CLICKHOUSE_PORT: "8123"
|
||||
ADMIN_HOST: 0.0.0.0
|
||||
ADMIN_PORT: "8000"
|
||||
ports:
|
||||
|
|
|
|||
|
|
@ -80,6 +80,8 @@ services:
|
|||
environment:
|
||||
DB_HOST: postgres
|
||||
DB_PORT: "5432"
|
||||
CLICKHOUSE_HOST: clickhouse
|
||||
CLICKHOUSE_PORT: "8123"
|
||||
ADMIN_HOST: 0.0.0.0
|
||||
ADMIN_PORT: "8000"
|
||||
ports:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
"""Тесты раздела «Логи» (без ClickHouse — чтение подменяется)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from common.analytics import read as analytics_read
|
||||
|
||||
EVENT: dict[str, Any] = {
|
||||
"event_time": datetime(2026, 10, 18, 17, 0, 0), # UTC, без tz — как отдаёт clickhouse-connect
|
||||
"event_type": "command",
|
||||
"user_id": 42,
|
||||
"username": "test_user",
|
||||
"chat_id": -100123,
|
||||
"payload": '{"command": "инфо", "args_count": 0}',
|
||||
"bot_version": "0.4.8",
|
||||
"prod": 0,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_read(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
||||
state: dict[str, Any] = {"total": 1}
|
||||
|
||||
async def fetch_events(settings: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], int]:
|
||||
state["kwargs"] = kwargs
|
||||
return [EVENT], state["total"]
|
||||
|
||||
monkeypatch.setattr(analytics_read, "fetch_events", fetch_events)
|
||||
return state
|
||||
|
||||
|
||||
def test_logs_page(fake_read: dict[str, Any], client_factory) -> None:
|
||||
response = client_factory().get("/logs")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "command" in response.text
|
||||
assert "@test_user" in response.text
|
||||
assert "инфо" in response.text
|
||||
assert "20:00:00" in response.text # 17:00 UTC -> 20:00 МСК
|
||||
|
||||
|
||||
def test_logs_filters_passed(fake_read: dict[str, Any], client_factory) -> None:
|
||||
client_factory().get(
|
||||
"/logs",
|
||||
params={
|
||||
"event_type": "command",
|
||||
"user": "42",
|
||||
"date_from": "2026-10-01",
|
||||
"date_to": "2026-10-18",
|
||||
},
|
||||
)
|
||||
|
||||
assert fake_read["kwargs"] == {
|
||||
"event_type": "command",
|
||||
"user": "42",
|
||||
"date_from": "2026-10-01",
|
||||
"date_to": "2026-10-18",
|
||||
"limit": 50,
|
||||
"offset": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_logs_pagination(fake_read: dict[str, Any], client_factory) -> None:
|
||||
fake_read["total"] = 120
|
||||
|
||||
response = client_factory().get("/logs", params={"page": "2"})
|
||||
|
||||
assert "Страница 2 из 3" in response.text
|
||||
assert fake_read["kwargs"]["offset"] == 50
|
||||
|
||||
|
||||
def test_logs_clickhouse_error(client_factory, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def fetch_events(settings: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], int]:
|
||||
raise RuntimeError("ClickHouse недоступен")
|
||||
|
||||
monkeypatch.setattr(analytics_read, "fetch_events", fetch_events)
|
||||
|
||||
response = client_factory().get("/logs")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "ClickHouse недоступен" in response.text
|
||||
|
||||
|
||||
def test_logs_bad_date(client_factory, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def fetch_events(settings: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], int]:
|
||||
raise ValueError("bad date")
|
||||
|
||||
monkeypatch.setattr(analytics_read, "fetch_events", fetch_events)
|
||||
|
||||
response = client_factory().get("/logs", params={"date_from": "18.10.2026"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "формате ГГГГ-ММ-ДД" in response.text
|
||||
|
|
@ -7,4 +7,4 @@ from common import __version__
|
|||
|
||||
def test_version_is_set():
|
||||
assert __version__
|
||||
assert __version__ == "0.4.7"
|
||||
assert __version__ == "0.4.8"
|
||||
|
|
|
|||
Loading…
Reference in New Issue