gendesign/tradein-mvp/backend/app/api/v1/audit.py
lekss361 9fd6396fbc
All checks were successful
Deploy Trade-In / build-backend (push) Successful in 57s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 30s
Deploy Trade-In / test (push) Successful in 4m49s
Deploy Trade-In / deploy (push) Successful in 50s
Deploy Trade-In / changes (push) Successful in 10s
feat(tradein/admin): audit + analytics read API over user_events (#2520)
Admin-only read API: GET /admin/audit/accounts, /admin/audit/accounts/{username}, /admin/analytics over user_events. Read-only, empty-safe, RBAC via central gate.
2026-07-13 20:26:58 +00:00

280 lines
9 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.

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