diff --git a/backend/api/admin/__init__.py b/backend/api/admin/__init__.py index f122afe..a5d47ed 100644 --- a/backend/api/admin/__init__.py +++ b/backend/api/admin/__init__.py @@ -13,11 +13,13 @@ main.py continues to use: app.include_router(admin_router) """ from fastapi import APIRouter +from api.admin.overview import router as overview_router from api.admin.users import router as users_router from api.admin.quotas import router as quotas_router from api.admin.ai import router as ai_router router = APIRouter(prefix="/api/admin", tags=["admin"]) +router.include_router(overview_router) router.include_router(users_router) router.include_router(quotas_router) router.include_router(ai_router) diff --git a/backend/api/admin/overview.py b/backend/api/admin/overview.py new file mode 100644 index 0000000..688b065 --- /dev/null +++ b/backend/api/admin/overview.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from db.models import AuditLog, Document, Quota, User +from deps.auth import get_current_admin +from deps.db import get_db +from api.audit import _build_filtered_query_with_handles +from api.audit import _audit_to_dict_with_handles + +router = APIRouter() # NO prefix — parent __init__.py carries /api/admin (D-02) + + +@router.get("/overview") +async def get_overview( + session: AsyncSession = Depends(get_db), + _admin: User = Depends(get_current_admin), +) -> dict: + user_count = await session.scalar( + select(func.count(User.id)).where(User.role == "user") + ) or 0 + + total_storage_bytes = await session.scalar( + select(func.sum(Quota.used_bytes)) + ) or 0 + + status_rows = ( + await session.execute( + select(Document.status, func.count(Document.id)).group_by(Document.status) + ) + ).all() + doc_status = {row[0]: row[1] for row in status_rows} + + audit_q = ( + _build_filtered_query_with_handles(None, None, None, None) + .order_by(AuditLog.created_at.desc()) + .limit(10) + ) + audit_rows = (await session.execute(audit_q)).all() + recent_audit = [ + _audit_to_dict_with_handles(row[0], row[1], row[2], row[3]) + for row in audit_rows + ] + + return { + "user_count": user_count, + "total_storage_bytes": total_storage_bytes, + "doc_status": doc_status, + "recent_audit": recent_audit, + } diff --git a/backend/tests/test_admin_overview.py b/backend/tests/test_admin_overview.py index 51eb39d..e56b805 100644 --- a/backend/tests/test_admin_overview.py +++ b/backend/tests/test_admin_overview.py @@ -90,7 +90,6 @@ async def admin_client(db_session: AsyncSession): @pytest.mark.asyncio -@pytest.mark.xfail(strict=True, reason="ADMIN-11: overview endpoint not yet implemented") async def test_overview_requires_admin(async_client: AsyncClient): """Unauthenticated GET /api/admin/overview returns 401 or 403.""" resp = await async_client.get("/api/admin/overview") @@ -98,7 +97,6 @@ async def test_overview_requires_admin(async_client: AsyncClient): @pytest.mark.asyncio -@pytest.mark.xfail(strict=True, reason="ADMIN-11: overview endpoint not yet implemented") async def test_overview_non_admin_forbidden(db_session: AsyncSession): """Regular-user JWT returns 403 from GET /api/admin/overview.""" from main import app @@ -116,7 +114,6 @@ async def test_overview_non_admin_forbidden(db_session: AsyncSession): @pytest.mark.asyncio -@pytest.mark.xfail(strict=True, reason="ADMIN-11: overview endpoint not yet implemented") async def test_overview_returns_expected_keys(admin_client): """Admin GET returns 200 with all four top-level keys present.""" c, admin, session = admin_client @@ -128,7 +125,6 @@ async def test_overview_returns_expected_keys(admin_client): @pytest.mark.asyncio -@pytest.mark.xfail(strict=True, reason="ADMIN-11: overview endpoint not yet implemented") async def test_overview_user_count_excludes_admins(admin_client): """user_count reflects only role='user' accounts, not admins.""" c, admin, session = admin_client @@ -146,7 +142,6 @@ async def test_overview_user_count_excludes_admins(admin_client): @pytest.mark.asyncio -@pytest.mark.xfail(strict=True, reason="ADMIN-11: overview endpoint not yet implemented") async def test_overview_total_storage_sums_quotas(admin_client): """total_storage_bytes sums used_bytes across all Quota rows.""" c, admin, session = admin_client @@ -173,7 +168,6 @@ async def test_overview_total_storage_sums_quotas(admin_client): @pytest.mark.asyncio -@pytest.mark.xfail(strict=True, reason="ADMIN-11: overview endpoint not yet implemented") async def test_overview_doc_status_groups_by_status(admin_client): """doc_status maps each status to its count.""" c, admin, session = admin_client @@ -204,7 +198,6 @@ async def test_overview_doc_status_groups_by_status(admin_client): @pytest.mark.asyncio -@pytest.mark.xfail(strict=True, reason="ADMIN-11: overview endpoint not yet implemented") async def test_overview_recent_audit_limit_10(admin_client): """recent_audit returns at most 10 entries ordered newest first.""" c, admin, session = admin_client @@ -240,7 +233,6 @@ async def test_overview_recent_audit_limit_10(admin_client): @pytest.mark.asyncio -@pytest.mark.xfail(strict=True, reason="ADMIN-11: overview endpoint not yet implemented") async def test_overview_no_sensitive_fields(admin_client): """Response body must not contain any sensitive field name.""" c, admin, session = admin_client