"""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], )