diff --git a/tradein-mvp/backend/app/api/v1/audit.py b/tradein-mvp/backend/app/api/v1/audit.py new file mode 100644 index 00000000..e592d5b1 --- /dev/null +++ b/tradein-mvp/backend/app/api/v1/audit.py @@ -0,0 +1,280 @@ +"""Admin read API над `user_events` — Feature 2 (login/IP audit) + Feature 3 (behavior +analytics dashboard data). + +Read-only: только SELECT, никаких мутаций. Auth не нужен в этом файле — вся ветка +`/api/v1/admin/*` уже гейтится `rbac_guard` middleware в app/main.py +(`_ADMIN_API_RE`, role != admin → 403). + +`user_events` (migration `184_user_events.sql`) может быть пустой (feature только +что задеплоена) — все запросы ниже написаны так, чтобы на пустой таблице отдавать +пустые списки/нулевые счётчики, а не падать. +""" + +from __future__ import annotations + +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, Path, Query +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core.db import get_db +from app.schemas.audit import ( + AccountActivityEntry, + AccountDeviceEntry, + AccountDrilldown, + AccountIpEntry, + AccountSearchEntry, + AccountSummary, + AnalyticsByAccount, + AnalyticsDailyPoint, + AnalyticsDashboard, + AnalyticsSummary, + AnalyticsTopPath, + AnalyticsTopSearch, +) + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.get("/audit/accounts", response_model=list[AccountSummary]) +async def list_accounts( + db: Annotated[Session, Depends(get_db)], +) -> list[AccountSummary]: + """Список аккаунтов, по одной строке на username, с базовой сводкой активности.""" + rows = ( + db.execute( + text( + """ + SELECT username, + min(created_at) AS first_seen_at, + max(created_at) AS last_seen_at, + count(DISTINCT ip_address) AS distinct_ips, + count(DISTINCT user_agent) AS distinct_devices, + count(*) FILTER (WHERE event_type = 'login') AS login_count, + count(*) FILTER (WHERE event_type = 'api_request') AS request_count, + count(*) FILTER (WHERE event_type = 'estimate_request') AS search_count + FROM user_events + GROUP BY username + ORDER BY last_seen_at DESC + """ + ) + ) + .mappings() + .all() + ) + return [AccountSummary.model_validate(r) for r in rows] + + +@router.get("/audit/accounts/{username}", response_model=AccountDrilldown) +async def account_drilldown( + db: Annotated[Session, Depends(get_db)], + username: Annotated[str, Path(min_length=1, max_length=200)], +) -> AccountDrilldown: + """Drilldown по одному username: IP-адреса, устройства, поиски, недавняя активность. + + Неизвестный username — НЕ 404, а пустой отчёт (все 4 списка == []): аудит не + подтверждает/опровергает существование аккаунта, просто нет событий. + """ + params = {"username": username} + + ips = ( + db.execute( + text( + """ + SELECT CAST(ip_address AS text) AS ip_address, + count(*) AS event_count, + min(created_at) AS first_seen, + max(created_at) AS last_seen + FROM user_events + WHERE username = :username AND ip_address IS NOT NULL + GROUP BY ip_address + ORDER BY last_seen DESC + """ + ), + params, + ) + .mappings() + .all() + ) + + devices = ( + db.execute( + text( + """ + SELECT user_agent, + count(*) AS event_count, + min(created_at) AS first_seen, + max(created_at) AS last_seen + FROM user_events + WHERE username = :username AND user_agent IS NOT NULL + GROUP BY user_agent + ORDER BY last_seen DESC + """ + ), + params, + ) + .mappings() + .all() + ) + + searches = ( + db.execute( + text( + """ + SELECT payload ->> 'address' AS address, + payload ->> 'area_m2' AS area_m2, + payload ->> 'rooms' AS rooms, + CAST(estimate_id AS text) AS estimate_id, + CAST(ip_address AS text) AS ip_address, + created_at + FROM user_events + WHERE username = :username AND event_type = 'estimate_request' + ORDER BY created_at DESC + LIMIT 200 + """ + ), + params, + ) + .mappings() + .all() + ) + + recent_activity = ( + db.execute( + text( + """ + SELECT event_type, + path, + method, + CAST(ip_address AS text) AS ip_address, + created_at + FROM user_events + WHERE username = :username + ORDER BY created_at DESC + LIMIT 200 + """ + ), + params, + ) + .mappings() + .all() + ) + + return AccountDrilldown( + ips=[AccountIpEntry.model_validate(r) for r in ips], + devices=[AccountDeviceEntry.model_validate(r) for r in devices], + searches=[AccountSearchEntry.model_validate(r) for r in searches], + recent_activity=[AccountActivityEntry.model_validate(r) for r in recent_activity], + ) + + +@router.get("/analytics", response_model=AnalyticsDashboard) +async def analytics_dashboard( + db: Annotated[Session, Depends(get_db)], + days: Annotated[int, Query(ge=1, le=365)] = 30, +) -> AnalyticsDashboard: + """Feature 3 dashboard bundle: сводка, дневной time-series, топ-поиски/пути/аккаунты.""" + summary_row = ( + db.execute( + text( + """ + SELECT count(*) AS total_events, + count(DISTINCT username) AS distinct_users, + count(*) FILTER ( + WHERE created_at >= now() - INTERVAL '24 hours' + ) AS events_last_24h, + count(DISTINCT username) FILTER ( + WHERE created_at >= now() - INTERVAL '24 hours' + ) AS active_users_last_24h + FROM user_events + """ + ) + ) + .mappings() + .one() + ) + + daily_rows = ( + db.execute( + text( + """ + SELECT date_trunc('day', created_at)::date AS day, + count(*) AS events, + count(DISTINCT username) AS users + FROM user_events + WHERE created_at >= now() - make_interval(days => CAST(:days AS int)) + GROUP BY date_trunc('day', created_at)::date + ORDER BY day + """ + ), + {"days": days}, + ) + .mappings() + .all() + ) + + top_searches_rows = ( + db.execute( + text( + """ + SELECT payload ->> 'address' AS address, count(*) AS n + FROM user_events + WHERE event_type = 'estimate_request' + AND payload ->> 'address' IS NOT NULL + GROUP BY payload ->> 'address' + ORDER BY n DESC + LIMIT 20 + """ + ) + ) + .mappings() + .all() + ) + + top_paths_rows = ( + db.execute( + text( + """ + SELECT path, count(*) AS n + FROM user_events + WHERE event_type = 'api_request' + GROUP BY path + ORDER BY n DESC + LIMIT 20 + """ + ) + ) + .mappings() + .all() + ) + + by_account_rows = ( + db.execute( + text( + """ + SELECT username, + count(*) AS events, + count(*) FILTER (WHERE event_type = 'estimate_request') AS searches, + max(created_at) AS last_seen + FROM user_events + GROUP BY username + ORDER BY events DESC + LIMIT 50 + """ + ) + ) + .mappings() + .all() + ) + + return AnalyticsDashboard( + summary=AnalyticsSummary.model_validate(summary_row), + daily=[AnalyticsDailyPoint.model_validate(r) for r in daily_rows], + top_searches=[AnalyticsTopSearch.model_validate(r) for r in top_searches_rows], + top_paths=[AnalyticsTopPath.model_validate(r) for r in top_paths_rows], + by_account=[AnalyticsByAccount.model_validate(r) for r in by_account_rows], + ) diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index 68ab300f..7e69155f 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -23,7 +23,7 @@ from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration from sentry_sdk.integrations.starlette import StarletteIntegration -from app.api.v1 import admin, brand, buildings, geocode, lead, me, search, trade_in +from app.api.v1 import admin, audit, brand, buildings, geocode, lead, me, search, trade_in from app.core.auth import get_role, is_path_allowed from app.core.config import settings from app.core.db import SessionLocal @@ -225,6 +225,7 @@ def health() -> dict[str, str]: app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"]) app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"]) +app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"]) app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"]) app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"]) app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"]) diff --git a/tradein-mvp/backend/app/schemas/audit.py b/tradein-mvp/backend/app/schemas/audit.py new file mode 100644 index 00000000..f5437cbd --- /dev/null +++ b/tradein-mvp/backend/app/schemas/audit.py @@ -0,0 +1,146 @@ +"""Pydantic-схемы admin read API над `user_events` (Feature 2 audit + Feature 3 analytics). + +`user_events` (migration `184_user_events.sql`) — unified append-only событийный лог +(login/IP audit + behavior analytics), admin-read-only. Эти схемы описывают ответы +`GET /api/v1/admin/audit/*` и `GET /api/v1/admin/analytics` (см. app/api/v1/audit.py). +""" + +from __future__ import annotations + +from datetime import date, datetime + +from pydantic import BaseModel, ConfigDict + + +class AccountSummary(BaseModel): + """Одна строка списка `GET /audit/accounts` — сводка по одному username.""" + + model_config = ConfigDict(from_attributes=True) + + username: str + first_seen_at: datetime + last_seen_at: datetime + distinct_ips: int + distinct_devices: int + login_count: int + request_count: int + search_count: int + + +class AccountIpEntry(BaseModel): + """Одна строка `ips` в drilldown `GET /audit/accounts/{username}`.""" + + model_config = ConfigDict(from_attributes=True) + + ip_address: str | None = None + event_count: int + first_seen: datetime + last_seen: datetime + + +class AccountDeviceEntry(BaseModel): + """Одна строка `devices` в drilldown `GET /audit/accounts/{username}`.""" + + model_config = ConfigDict(from_attributes=True) + + user_agent: str | None = None + event_count: int + first_seen: datetime + last_seen: datetime + + +class AccountSearchEntry(BaseModel): + """Одна строка `searches` в drilldown — недавний estimate_request.""" + + model_config = ConfigDict(from_attributes=True) + + address: str | None = None + area_m2: str | None = None + rooms: str | None = None + estimate_id: str | None = None + ip_address: str | None = None + created_at: datetime + + +class AccountActivityEntry(BaseModel): + """Одна строка `recent_activity` в drilldown — сырое событие.""" + + model_config = ConfigDict(from_attributes=True) + + event_type: str + path: str | None = None + method: str | None = None + ip_address: str | None = None + created_at: datetime + + +class AccountDrilldown(BaseModel): + """Полный ответ `GET /audit/accounts/{username}` — 4 списка. + + Неизвестный username НЕ 404 — это просто пустой отчёт (все списки == []). + """ + + ips: list[AccountIpEntry] + devices: list[AccountDeviceEntry] + searches: list[AccountSearchEntry] + recent_activity: list[AccountActivityEntry] + + +class AnalyticsSummary(BaseModel): + """Верхнеуровневые счётчики дашборда `GET /analytics`.""" + + model_config = ConfigDict(from_attributes=True) + + total_events: int + distinct_users: int + events_last_24h: int + active_users_last_24h: int + + +class AnalyticsDailyPoint(BaseModel): + """Одна точка time-series `daily` — события/пользователи за день.""" + + model_config = ConfigDict(from_attributes=True) + + day: date + events: int + users: int + + +class AnalyticsTopSearch(BaseModel): + """Одна строка `top_searches` — самый частый искомый адрес.""" + + model_config = ConfigDict(from_attributes=True) + + address: str | None = None + n: int + + +class AnalyticsTopPath(BaseModel): + """Одна строка `top_paths` — самый частый API-путь.""" + + model_config = ConfigDict(from_attributes=True) + + path: str | None = None + n: int + + +class AnalyticsByAccount(BaseModel): + """Одна строка `by_account` — сводка активности по username.""" + + model_config = ConfigDict(from_attributes=True) + + username: str + events: int + searches: int + last_seen: datetime + + +class AnalyticsDashboard(BaseModel): + """Полный ответ `GET /analytics` — бандл для дашборда Feature 3.""" + + summary: AnalyticsSummary + daily: list[AnalyticsDailyPoint] + top_searches: list[AnalyticsTopSearch] + top_paths: list[AnalyticsTopPath] + by_account: list[AnalyticsByAccount] diff --git a/tradein-mvp/backend/tests/test_audit_api.py b/tradein-mvp/backend/tests/test_audit_api.py new file mode 100644 index 00000000..78ec6684 --- /dev/null +++ b/tradein-mvp/backend/tests/test_audit_api.py @@ -0,0 +1,344 @@ +"""Tests for app.api.v1.audit — admin read API over `user_events` (Feature 2/3). + +Coverage: + (a) static SQL guard — CAST(:x AS type), never `:x::type` (psycopg v3 trap). + (b) GET /audit/accounts — empty table → [] (never errors); populated → shape-valid + AccountSummary rows pass through response_model unchanged. + (c) GET /audit/accounts/{username} — empty table (incl. unknown username, NOT 404) → + all 4 lists == []; populated → shape-valid drilldown rows. + (d) GET /analytics — empty table → zeroed summary + empty lists (never errors); + populated → shape-valid dashboard bundle, `days` query param clamped [1, 365]. + (e) optional real-Postgres round trip — inserts a couple of user_events rows and + asserts aggregation through the real DB; self-SKIPS without a reachable, + non-placeholder Postgres (mirrors tests/test_house_dedup_merge.py's live-DB + pattern). + +Router is tested in isolation on a minimal FastAPI app (mirrors tests/test_ratelimit.py +and the quota_app fixture in tests/test_user_events.py) — no need to pull in the full +app.main (sentry/scheduler/CORS/rate-limit wiring). +""" + +from __future__ import annotations + +import inspect +import os +import re +import uuid +from datetime import UTC, date, datetime +from typing import Any + +# psycopg v3 driver required; stub DATABASE_URL before any app import (mirrors other tests). +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.v1 import audit as audit_module +from app.core.db import get_db + +# --------------------------------------------------------------------------- +# (a) Static SQL guard +# --------------------------------------------------------------------------- + +_AUDIT_SRC = inspect.getsource(audit_module) + + +def test_no_psycopg_v3_doublecolon_cast() -> None: + """CAST(:x AS type) — НИКОГДА `:x::type` (psycopg v3 trap, .claude/rules/backend.md).""" + assert not re.search(r":\w+::\w", _AUDIT_SRC) + + +def test_days_param_uses_cast_as_int() -> None: + assert "CAST(:days AS int)" in _AUDIT_SRC + + +# --------------------------------------------------------------------------- +# Fakes — mirror the mocked-DB convention used across tests/test_user_events.py etc. +# --------------------------------------------------------------------------- + + +class _FakeMappingResult: + def __init__(self, rows: list[dict[str, Any]]) -> None: + self._rows = rows + + def all(self) -> list[dict[str, Any]]: + return list(self._rows) + + def one(self) -> dict[str, Any]: + return self._rows[0] + + +class _FakeExecResult: + def __init__(self, rows: list[dict[str, Any]]) -> None: + self._rows = rows + + def mappings(self) -> _FakeMappingResult: + return _FakeMappingResult(self._rows) + + +class _FakeSession: + """Returns queued row-lists in the exact order audit.py issues db.execute() calls.""" + + def __init__(self, responses: list[list[dict[str, Any]]]) -> None: + self._responses = list(responses) + self._i = 0 + + def execute(self, _stmt: object, _params: dict[str, Any] | None = None) -> _FakeExecResult: + rows = self._responses[self._i] + self._i += 1 + return _FakeExecResult(rows) + + +def _make_app(responses: list[list[dict[str, Any]]]) -> FastAPI: + application = FastAPI() + application.include_router(audit_module.router, prefix="/api/v1/admin") + + def _override_db() -> Any: + yield _FakeSession(responses) + + application.dependency_overrides[get_db] = _override_db + return application + + +_NOW = datetime(2026, 7, 13, 12, 0, 0, tzinfo=UTC) + +# --------------------------------------------------------------------------- +# (b) GET /audit/accounts +# --------------------------------------------------------------------------- + + +def test_list_accounts_empty_table() -> None: + app = _make_app(responses=[[]]) + client = TestClient(app) + resp = client.get("/api/v1/admin/audit/accounts") + assert resp.status_code == 200 + assert resp.json() == [] + + +def test_list_accounts_populated_shape() -> None: + row = { + "username": "user1", + "first_seen_at": _NOW, + "last_seen_at": _NOW, + "distinct_ips": 3, + "distinct_devices": 2, + "login_count": 5, + "request_count": 40, + "search_count": 7, + } + app = _make_app(responses=[[row]]) + client = TestClient(app) + resp = client.get("/api/v1/admin/audit/accounts") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["username"] == "user1" + assert data[0]["distinct_ips"] == 3 + assert data[0]["login_count"] == 5 + assert data[0]["request_count"] == 40 + assert data[0]["search_count"] == 7 + + +# --------------------------------------------------------------------------- +# (c) GET /audit/accounts/{username} +# --------------------------------------------------------------------------- + + +def test_account_drilldown_empty_table_unknown_user_not_404() -> None: + """Unknown username → 200 with all 4 lists empty, NOT a 404.""" + app = _make_app(responses=[[], [], [], []]) + client = TestClient(app) + resp = client.get("/api/v1/admin/audit/accounts/ghost_user_xyz") + assert resp.status_code == 200 + data = resp.json() + assert data == {"ips": [], "devices": [], "searches": [], "recent_activity": []} + + +def test_account_drilldown_populated_shape() -> None: + ips = [{"ip_address": "1.2.3.4", "event_count": 10, "first_seen": _NOW, "last_seen": _NOW}] + devices = [ + {"user_agent": "pytest-agent", "event_count": 10, "first_seen": _NOW, "last_seen": _NOW} + ] + searches = [ + { + "address": "ул. Малышева, 1", + "area_m2": "50.0", + "rooms": "2", + "estimate_id": str(uuid.uuid4()), + "ip_address": "1.2.3.4", + "created_at": _NOW, + } + ] + recent_activity = [ + { + "event_type": "api_request", + "path": "/api/v1/trade-in/estimate", + "method": "POST", + "ip_address": "1.2.3.4", + "created_at": _NOW, + } + ] + app = _make_app(responses=[ips, devices, searches, recent_activity]) + client = TestClient(app) + resp = client.get("/api/v1/admin/audit/accounts/user1") + assert resp.status_code == 200 + data = resp.json() + assert data["ips"][0]["ip_address"] == "1.2.3.4" + assert data["ips"][0]["event_count"] == 10 + assert data["devices"][0]["user_agent"] == "pytest-agent" + assert data["searches"][0]["address"] == "ул. Малышева, 1" + assert data["searches"][0]["area_m2"] == "50.0" + assert data["recent_activity"][0]["event_type"] == "api_request" + assert data["recent_activity"][0]["path"] == "/api/v1/trade-in/estimate" + + +# --------------------------------------------------------------------------- +# (d) GET /analytics +# --------------------------------------------------------------------------- + +_ZERO_SUMMARY = { + "total_events": 0, + "distinct_users": 0, + "events_last_24h": 0, + "active_users_last_24h": 0, +} + + +def test_analytics_empty_table_never_errors() -> None: + app = _make_app(responses=[[_ZERO_SUMMARY], [], [], [], []]) + client = TestClient(app) + resp = client.get("/api/v1/admin/analytics") + assert resp.status_code == 200 + data = resp.json() + assert data["summary"] == _ZERO_SUMMARY + assert data["daily"] == [] + assert data["top_searches"] == [] + assert data["top_paths"] == [] + assert data["by_account"] == [] + + +def test_analytics_populated_shape() -> None: + summary = { + "total_events": 120, + "distinct_users": 4, + "events_last_24h": 15, + "active_users_last_24h": 2, + } + daily = [{"day": date(2026, 7, 13), "events": 15, "users": 2}] + top_searches = [{"address": "ул. Малышева, 1", "n": 3}] + top_paths = [{"path": "/api/v1/trade-in/estimate", "n": 40}] + by_account = [{"username": "user1", "events": 60, "searches": 7, "last_seen": _NOW}] + app = _make_app(responses=[[summary], daily, top_searches, top_paths, by_account]) + client = TestClient(app) + resp = client.get("/api/v1/admin/analytics") + assert resp.status_code == 200 + data = resp.json() + assert data["summary"]["total_events"] == 120 + assert data["daily"][0]["events"] == 15 + assert data["daily"][0]["day"] == "2026-07-13" + assert data["top_searches"][0]["address"] == "ул. Малышева, 1" + assert data["top_paths"][0]["n"] == 40 + assert data["by_account"][0]["username"] == "user1" + + +def test_analytics_days_query_param_clamped() -> None: + """days=0 (below ge=1) → 422; days=9999 (above le=365) → 422.""" + app = _make_app(responses=[[_ZERO_SUMMARY], [], [], [], []]) + client = TestClient(app) + assert client.get("/api/v1/admin/analytics?days=0").status_code == 422 + assert client.get("/api/v1/admin/analytics?days=9999").status_code == 422 + + +def test_analytics_days_query_param_default_30() -> None: + app = _make_app(responses=[[_ZERO_SUMMARY], [], [], [], []]) + client = TestClient(app) + resp = client.get("/api/v1/admin/analytics") + assert resp.status_code == 200 + + +def test_analytics_days_query_param_accepted_in_range() -> None: + app = _make_app(responses=[[_ZERO_SUMMARY], [], [], [], []]) + client = TestClient(app) + resp = client.get("/api/v1/admin/analytics?days=7") + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# (e) Optional real-Postgres round trip (self-skips without a reachable DB) +# --------------------------------------------------------------------------- + + +def _live_session() -> Any | None: + """Return a SQLAlchemy Session if a non-placeholder Postgres is reachable, else None.""" + try: + from sqlalchemy import create_engine + from sqlalchemy import text as _t + from sqlalchemy.orm import sessionmaker + + dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "") + if not dsn or "localhost:5432/test" in dsn: + return None + engine = create_engine(dsn, future=True) + conn = engine.connect() + conn.execute(_t("SELECT 1")) + conn.close() + return sessionmaker(bind=engine, future=True)() + except Exception: + return None + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_accounts_and_analytics_aggregate_inserted_rows() -> None: + """End-to-end on a real DB: insert 2 user_events rows for a throwaway test username, + assert /audit/accounts, /audit/accounts/{username} and /analytics reflect them.""" + from sqlalchemy import text as _t + + from app.core.db import SessionLocal + + db = _live_session() + assert db is not None + username = f"audit_test_{uuid.uuid4().hex[:8]}" + try: + db.execute( + _t( + """ + INSERT INTO user_events + (event_type, username, ip_address, user_agent, path, method, payload) + VALUES + ('login', :u, CAST('9.9.9.9' AS inet), 'pytest-ua', NULL, NULL, + CAST('{}' AS jsonb)), + ('estimate_request', :u, CAST('9.9.9.9' AS inet), 'pytest-ua', + '/api/v1/trade-in/estimate', 'POST', + CAST(:payload AS jsonb)) + """ + ), + {"u": username, "payload": '{"address": "ул. Тестовая, 1", "area_m2": "42.0"}'}, + ) + db.commit() + + application = FastAPI() + application.include_router(audit_module.router, prefix="/api/v1/admin") + + def _override_db() -> Any: + yield SessionLocal() + + application.dependency_overrides[get_db] = _override_db + client = TestClient(application) + + accounts = client.get("/api/v1/admin/audit/accounts").json() + row = next(r for r in accounts if r["username"] == username) + assert row["login_count"] == 1 + assert row["search_count"] == 1 + + drilldown = client.get(f"/api/v1/admin/audit/accounts/{username}").json() + assert len(drilldown["searches"]) == 1 + assert drilldown["searches"][0]["address"] == "ул. Тестовая, 1" + assert len(drilldown["recent_activity"]) == 2 + + dashboard = client.get("/api/v1/admin/analytics?days=1").json() + assert dashboard["summary"]["total_events"] >= 2 + finally: + db.rollback() + db.execute(_t("DELETE FROM user_events WHERE username = :u"), {"u": username}) + db.commit()