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):
|
def require_admin(request: Request):
|
||||||
|
cached = getattr(request.state, "admin_user", None)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
session_id = request.cookies.get(SESSION_COOKIE_NAME)
|
session_id = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
if not session_id:
|
if not session_id:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
@ -37,14 +41,20 @@ def require_admin(request: Request):
|
|||||||
role = _resolve_role(row)
|
role = _resolve_role(row)
|
||||||
if role not in ("ADMIN", "SUPER_ADMIN"):
|
if role not in ("ADMIN", "SUPER_ADMIN"):
|
||||||
raise HTTPException(status_code=403, detail="Admin access required")
|
raise HTTPException(status_code=403, detail="Admin access required")
|
||||||
return {
|
admin_user = {
|
||||||
"id": row[0],
|
"id": row[0],
|
||||||
"username": row[1],
|
"username": row[1],
|
||||||
"role": role,
|
"role": role,
|
||||||
}
|
}
|
||||||
|
request.state.admin_user = admin_user
|
||||||
|
return admin_user
|
||||||
|
|
||||||
|
|
||||||
def require_super_admin(request: Request):
|
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)
|
session_id = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
if not session_id:
|
if not session_id:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
@ -64,8 +74,10 @@ def require_super_admin(request: Request):
|
|||||||
role = _resolve_role(row)
|
role = _resolve_role(row)
|
||||||
if role != "SUPER_ADMIN":
|
if role != "SUPER_ADMIN":
|
||||||
raise HTTPException(status_code=403, detail="Super admin access required")
|
raise HTTPException(status_code=403, detail="Super admin access required")
|
||||||
return {
|
admin_user = {
|
||||||
"id": row[0],
|
"id": row[0],
|
||||||
"username": row[1],
|
"username": row[1],
|
||||||
"role": role,
|
"role": role,
|
||||||
}
|
}
|
||||||
|
request.state.admin_user = admin_user
|
||||||
|
return admin_user
|
||||||
|
|||||||
@ -13,6 +13,13 @@ class TopError(BaseModel):
|
|||||||
run_id: Optional[str]
|
run_id: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
class AdminAccessResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
username: str
|
||||||
|
role: str
|
||||||
|
can_manage_admins: bool
|
||||||
|
|
||||||
|
|
||||||
class OverviewResponse(BaseModel):
|
class OverviewResponse(BaseModel):
|
||||||
total_users: int
|
total_users: int
|
||||||
users_logged_in_last_24h: int
|
users_logged_in_last_24h: int
|
||||||
@ -25,6 +32,9 @@ class OverviewResponse(BaseModel):
|
|||||||
orders_last_24h: int
|
orders_last_24h: int
|
||||||
trades_last_24h: int
|
trades_last_24h: int
|
||||||
sip_executed_last_24h: int
|
sip_executed_last_24h: int
|
||||||
|
unresolved_orders: int
|
||||||
|
blocked_strategies: int
|
||||||
|
open_support_tickets: int
|
||||||
top_errors: list[TopError]
|
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_auth import require_admin, require_super_admin
|
||||||
from app.admin_models import (
|
from app.admin_models import (
|
||||||
|
AdminAccessResponse,
|
||||||
DeleteUserResponse,
|
DeleteUserResponse,
|
||||||
HardResetResponse,
|
HardResetResponse,
|
||||||
InvariantsResponse,
|
InvariantsResponse,
|
||||||
@ -30,6 +31,16 @@ from app.admin_role_service import set_user_role
|
|||||||
router = APIRouter(prefix="/api/admin", dependencies=[Depends(require_admin)])
|
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)
|
@router.get("/overview", response_model=OverviewResponse)
|
||||||
def admin_overview():
|
def admin_overview():
|
||||||
return get_overview()
|
return get_overview()
|
||||||
|
|||||||
@ -17,6 +17,11 @@ def _paginate(page: int, page_size: int):
|
|||||||
return page, page_size, offset
|
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():
|
def get_overview():
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
since = now - timedelta(hours=24)
|
since = now - timedelta(hours=24)
|
||||||
@ -69,6 +74,49 @@ def get_overview():
|
|||||||
(since,),
|
(since,),
|
||||||
)
|
)
|
||||||
sip_executed_last_24h = cur.fetchone()[0]
|
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(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT ts, event, message, source, user_id, run_id
|
SELECT ts, event, message, source, user_id, run_id
|
||||||
@ -108,6 +156,9 @@ def get_overview():
|
|||||||
"orders_last_24h": orders_last_24h,
|
"orders_last_24h": orders_last_24h,
|
||||||
"trades_last_24h": trades_last_24h,
|
"trades_last_24h": trades_last_24h,
|
||||||
"sip_executed_last_24h": sip_executed_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,
|
"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