DispelEvilBot/common/analytics/read.py

126 lines
4.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Чтение событий 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