Stabilize admin API access and overview metrics

This commit is contained in:
Thigazhezhilan J 2026-04-10 00:37:07 +05:30
parent 9c171ba799
commit d857f9d703
5 changed files with 239 additions and 2 deletions

View File

@ -18,6 +18,10 @@ def _resolve_role(row) -> str:
def require_admin(request: Request):
cached = getattr(request.state, "admin_user", None)
if cached:
return cached
session_id = request.cookies.get(SESSION_COOKIE_NAME)
if not session_id:
raise HTTPException(status_code=401, detail="Not authenticated")
@ -37,14 +41,20 @@ def require_admin(request: Request):
role = _resolve_role(row)
if role not in ("ADMIN", "SUPER_ADMIN"):
raise HTTPException(status_code=403, detail="Admin access required")
return {
admin_user = {
"id": row[0],
"username": row[1],
"role": role,
}
request.state.admin_user = admin_user
return admin_user
def require_super_admin(request: Request):
cached = getattr(request.state, "admin_user", None)
if cached and cached.get("role") == "SUPER_ADMIN":
return cached
session_id = request.cookies.get(SESSION_COOKIE_NAME)
if not session_id:
raise HTTPException(status_code=401, detail="Not authenticated")
@ -64,8 +74,10 @@ def require_super_admin(request: Request):
role = _resolve_role(row)
if role != "SUPER_ADMIN":
raise HTTPException(status_code=403, detail="Super admin access required")
return {
admin_user = {
"id": row[0],
"username": row[1],
"role": role,
}
request.state.admin_user = admin_user
return admin_user

View File

@ -13,6 +13,13 @@ class TopError(BaseModel):
run_id: Optional[str]
class AdminAccessResponse(BaseModel):
id: str
username: str
role: str
can_manage_admins: bool
class OverviewResponse(BaseModel):
total_users: int
users_logged_in_last_24h: int
@ -25,6 +32,9 @@ class OverviewResponse(BaseModel):
orders_last_24h: int
trades_last_24h: int
sip_executed_last_24h: int
unresolved_orders: int
blocked_strategies: int
open_support_tickets: int
top_errors: list[TopError]

View File

@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from app.admin_auth import require_admin, require_super_admin
from app.admin_models import (
AdminAccessResponse,
DeleteUserResponse,
HardResetResponse,
InvariantsResponse,
@ -30,6 +31,16 @@ from app.admin_role_service import set_user_role
router = APIRouter(prefix="/api/admin", dependencies=[Depends(require_admin)])
@router.get("/access", response_model=AdminAccessResponse)
def admin_access(admin_user: dict = Depends(require_admin)):
return {
"id": admin_user["id"],
"username": admin_user["username"],
"role": admin_user["role"],
"can_manage_admins": admin_user["role"] == "SUPER_ADMIN",
}
@router.get("/overview", response_model=OverviewResponse)
def admin_overview():
return get_overview()

View File

@ -17,6 +17,11 @@ def _paginate(page: int, page_size: int):
return page, page_size, offset
def _table_exists(cur, table_name: str) -> bool:
cur.execute("SELECT to_regclass(%s)", (table_name,))
return cur.fetchone()[0] is not None
def get_overview():
now = datetime.now(timezone.utc)
since = now - timedelta(hours=24)
@ -69,6 +74,49 @@ def get_overview():
(since,),
)
sip_executed_last_24h = cur.fetchone()[0]
unresolved_orders = 0
if _table_exists(cur, "broker_order_state"):
cur.execute(
"""
SELECT COUNT(*)
FROM broker_order_state
WHERE needs_reconciliation = TRUE
OR status IN ('PENDING', 'PARTIAL', 'UNKNOWN')
"""
)
unresolved_orders = cur.fetchone()[0]
blocked_strategies = 0
if _table_exists(cur, "user_broker"):
cur.execute(
"""
SELECT COUNT(*)
FROM strategy_run sr
LEFT JOIN user_broker ub
ON ub.user_id = sr.user_id
WHERE sr.mode = 'LIVE'
AND sr.status IN ('RUNNING', 'PAUSED_AUTH_EXPIRED')
AND (
ub.user_id IS NULL
OR COALESCE(ub.connected, FALSE) = FALSE
OR COALESCE(UPPER(NULLIF(TRIM(ub.auth_state), '')), 'VALID') NOT IN ('VALID', 'CONNECTED')
)
"""
)
blocked_strategies = cur.fetchone()[0]
open_support_tickets = 0
if _table_exists(cur, "support_ticket"):
cur.execute(
"""
SELECT COUNT(*)
FROM support_ticket
WHERE COALESCE(UPPER(NULLIF(TRIM(status), '')), 'NEW') NOT IN ('CLOSED', 'RESOLVED')
"""
)
open_support_tickets = cur.fetchone()[0]
cur.execute(
"""
SELECT ts, event, message, source, user_id, run_id
@ -108,6 +156,9 @@ def get_overview():
"orders_last_24h": orders_last_24h,
"trades_last_24h": trades_last_24h,
"sip_executed_last_24h": sip_executed_last_24h,
"unresolved_orders": unresolved_orders,
"blocked_strategies": blocked_strategies,
"open_support_tickets": open_support_tickets,
"top_errors": top_errors,
}

View File

@ -0,0 +1,153 @@
import importlib
from contextlib import contextmanager
from fastapi.testclient import TestClient
def _build_app(monkeypatch):
monkeypatch.setenv("APP_ENV", "test")
monkeypatch.setenv("DISABLE_STARTUP_TASKS", "1")
monkeypatch.setenv("DB_HOST", "localhost")
monkeypatch.setenv("DB_NAME", "trading_db")
monkeypatch.setenv("DB_USER", "trader")
monkeypatch.setenv("DB_PASSWORD", "test-password")
monkeypatch.setenv("CORS_ORIGINS", "http://localhost:3000")
monkeypatch.setenv("BROKER_TOKEN_KEY", "test-broker-token-key")
monkeypatch.setenv("RESET_OTP_SECRET", "test-reset-secret")
import app.main as app_main
importlib.reload(app_main)
return app_main.create_app()
class _FakeCursor:
def __init__(self, row):
self._row = row
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def execute(self, _query, _params=None):
return None
def fetchone(self):
return self._row
class _FakeConnection:
def __init__(self, row):
self._row = row
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def cursor(self, *args, **kwargs):
return _FakeCursor(self._row)
@contextmanager
def _fake_db_connection(row):
yield _FakeConnection(row)
def test_admin_access_requires_auth(monkeypatch):
app = _build_app(monkeypatch)
client = TestClient(app)
response = client.get("/api/admin/access")
assert response.status_code == 401
assert response.json() == {"detail": "Not authenticated"}
def test_admin_access_forbids_non_admin(monkeypatch):
app = _build_app(monkeypatch)
client = TestClient(app)
import app.admin_auth as admin_auth
monkeypatch.setattr(admin_auth, "get_user_for_session", lambda _sid: {"id": "user-1"})
monkeypatch.setattr(
admin_auth,
"db_connection",
lambda: _fake_db_connection(("user-1", "normal@example.com", "USER", False, False)),
)
response = client.get("/api/admin/access", cookies={"session_id": "session-1"})
assert response.status_code == 403
assert response.json() == {"detail": "Admin access required"}
def test_admin_access_returns_identity(monkeypatch):
app = _build_app(monkeypatch)
client = TestClient(app)
import app.admin_auth as admin_auth
monkeypatch.setattr(admin_auth, "get_user_for_session", lambda _sid: {"id": "admin-1"})
monkeypatch.setattr(
admin_auth,
"db_connection",
lambda: _fake_db_connection(("admin-1", "admin@example.com", "ADMIN", True, False)),
)
response = client.get("/api/admin/access", cookies={"session_id": "session-admin"})
assert response.status_code == 200
assert response.json() == {
"id": "admin-1",
"username": "admin@example.com",
"role": "ADMIN",
"can_manage_admins": False,
}
def test_admin_overview_returns_data(monkeypatch):
app = _build_app(monkeypatch)
client = TestClient(app)
import app.admin_auth as admin_auth
import app.admin_router as admin_router
monkeypatch.setattr(admin_auth, "get_user_for_session", lambda _sid: {"id": "admin-1"})
monkeypatch.setattr(
admin_auth,
"db_connection",
lambda: _fake_db_connection(("admin-1", "admin@example.com", "SUPER_ADMIN", True, True)),
)
monkeypatch.setattr(
admin_router,
"get_overview",
lambda: {
"total_users": 2,
"users_logged_in_last_24h": 1,
"total_runs": 3,
"running_runs": 1,
"stopped_runs": 1,
"error_runs": 1,
"live_runs_count": 1,
"paper_runs_count": 2,
"orders_last_24h": 5,
"trades_last_24h": 4,
"sip_executed_last_24h": 1,
"unresolved_orders": 2,
"blocked_strategies": 1,
"open_support_tickets": 3,
"top_errors": [],
},
)
response = client.get("/api/admin/overview", cookies={"session_id": "session-admin"})
assert response.status_code == 200
assert response.json()["unresolved_orders"] == 2
assert response.json()["blocked_strategies"] == 1
assert response.json()["open_support_tickets"] == 3