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
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.
280 lines
9 KiB
Python
280 lines
9 KiB
Python
"""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],
|
||
)
|