Stabilize admin API access and overview metrics
This commit is contained in:
parent
9c171ba799
commit
d857f9d703
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
153
backend/tests/test_admin_dashboard.py
Normal file
153
backend/tests/test_admin_dashboard.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user