diff --git a/backend/app/admin_auth.py b/backend/app/admin_auth.py index f1d4646..d244917 100644 --- a/backend/app/admin_auth.py +++ b/backend/app/admin_auth.py @@ -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 diff --git a/backend/app/admin_models.py b/backend/app/admin_models.py index 6c9ca86..581d852 100644 --- a/backend/app/admin_models.py +++ b/backend/app/admin_models.py @@ -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] diff --git a/backend/app/admin_router.py b/backend/app/admin_router.py index 376e87f..d8b2db4 100644 --- a/backend/app/admin_router.py +++ b/backend/app/admin_router.py @@ -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() diff --git a/backend/app/admin_service.py b/backend/app/admin_service.py index 3c6a51d..8bd7c4d 100644 --- a/backend/app/admin_service.py +++ b/backend/app/admin_service.py @@ -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, } diff --git a/backend/tests/test_admin_dashboard.py b/backend/tests/test_admin_dashboard.py new file mode 100644 index 0000000..e6f3bf7 --- /dev/null +++ b/backend/tests/test_admin_dashboard.py @@ -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