diff --git a/app/container.py b/app/container.py index 4d08b1f8..5311498a 100644 --- a/app/container.py +++ b/app/container.py @@ -37,7 +37,10 @@ from db.generated import stuff_user as staff_queries from db.generated import notifications as notification_queries from db.generated import audit as audit_queries +from db.generated import stats as stats_queries + from app.service.event import EventService +from app.service.stats import StatsService from app.worker.notification.notification_queue import NotificationQueue from app.worker.notification.settings import NotifSetting @@ -70,7 +73,7 @@ def __init__( self.event_querier = event_queries.AsyncQuerier(conn) self.participant_querier = participant_queries.AsyncQuerier(conn) self.staff_querier = staff_queries.AsyncQuerier(conn) - + self.stats_querier = stats_queries.AsyncQuerier(conn) # services self.session_service = SessionService() @@ -150,6 +153,10 @@ def __init__( staff_drive_service=self.staff_drive_service, ) + self.stats_service = StatsService( + querier=self.stats_querier, + ) + async def get_container( conn: sqlalchemy.ext.asyncio.AsyncConnection = Depends(get_db), ) -> Container: diff --git a/app/deps/cookie_auth.py b/app/deps/cookie_auth.py index 749870bd..ae9b3885 100644 --- a/app/deps/cookie_auth.py +++ b/app/deps/cookie_auth.py @@ -50,3 +50,14 @@ async def require_multi_team_lead_staff( current_staff_user: Annotated[StaffUser, Depends(get_current_staff_user)], ) -> StaffUser: return ensure_multi_team_lead_staff(current_staff_user) + + +def ensure_admin_staff(current_staff_user: StaffUser) -> StaffUser: + if _role_value(current_staff_user.role) != StaffRole.ADMIN.value: + raise AppException.forbidden("Admin access required") + return current_staff_user + +async def require_admin_staff( + current_staff_user: Annotated[StaffUser, Depends(get_current_staff_user)], +) -> StaffUser: + return ensure_admin_staff(current_staff_user) diff --git a/app/router/web/__init__.py b/app/router/web/__init__.py index b7939c39..1e5390a8 100644 --- a/app/router/web/__init__.py +++ b/app/router/web/__init__.py @@ -4,6 +4,7 @@ from app.router.web.auth import router as auth_routes from app.router.web.audit import router as audit_router from app.router.web.users import router as users_router +from app.router.web.stats import router as stats_router router = APIRouter(prefix="/admin", tags=["admin"]) router.include_router(staff_users_router) @@ -11,3 +12,4 @@ router.include_router(auth_routes) router.include_router(audit_router) router.include_router(users_router) +router.include_router(stats_router) diff --git a/app/router/web/stats.py b/app/router/web/stats.py new file mode 100644 index 00000000..3d9bb46c --- /dev/null +++ b/app/router/web/stats.py @@ -0,0 +1,45 @@ +from fastapi import APIRouter, Depends +from app.container import Container, get_container +from app.deps.cookie_auth import require_admin_staff +from db.generated.models import StaffUser +from app.schema.response.web.stats import ( + AdminStatsResponse, DriveUsageResponse, + ProcessingLoadResponse, AlertResponse +) + +router = APIRouter(prefix="/stats", tags=["Web - Stats"]) + +@router.get("/dashboard", response_model=AdminStatsResponse) +async def get_dashboard( + container: Container = Depends(get_container), + current_admin: StaffUser = Depends(require_admin_staff) +) -> AdminStatsResponse: + """Staff Admin Only: Get global KPIs for the dashboard""" + return await container.stats_service.get_dashboard_stats() + + +@router.get("/processing-load", response_model=ProcessingLoadResponse) +async def get_processing_load( + container: Container = Depends(get_container), + current_admin: StaffUser = Depends(require_admin_staff) +) -> ProcessingLoadResponse: + """Staff Admin Only: Get pipeline processing load percentages""" + return await container.stats_service.get_processing_load() + + +@router.get("/storage", response_model=DriveUsageResponse) +async def get_storage( + container: Container = Depends(get_container), + current_admin: StaffUser = Depends(require_admin_staff) +) -> DriveUsageResponse: + """Staff Admin Only: Get MinIO storage consumption""" + return await container.stats_service.get_storage_usage() + + +@router.get("/alerts", response_model=AlertResponse) +async def get_alerts( + container: Container = Depends(get_container), + current_admin: StaffUser = Depends(require_admin_staff) +) -> AlertResponse: + """Staff Admin Only: Get recent alerts/notifications for the admin""" + return await container.stats_service.get_staff_alerts(current_admin.id) diff --git a/app/schema/response/web/stats.py b/app/schema/response/web/stats.py new file mode 100644 index 00000000..f3bcdbc4 --- /dev/null +++ b/app/schema/response/web/stats.py @@ -0,0 +1,35 @@ +from pydantic import BaseModel +from datetime import datetime +from typing import List, Optional + +class AdminStatsResponse(BaseModel): + active_events: int + photos_uploaded: int + processed_photos: int + queue_size: int + timestamp: datetime + +class DriveUsageResponse(BaseModel): + used_bytes: int + total_bytes: int + timestamp: datetime + +class AlertItem(BaseModel): + id: str + type: str + title: str + message: str + created_at: datetime + is_read: bool + is_actionable: Optional[bool] = False + action_text: Optional[str] = None + +class AlertResponse(BaseModel): + alerts: List[AlertItem] + unread_count: int + timestamp: datetime + +class ProcessingLoadResponse(BaseModel): + completed: float + processing: float + queued: float diff --git a/app/service/photo_approval.py b/app/service/photo_approval.py index f3eb809c..6af86bc4 100644 --- a/app/service/photo_approval.py +++ b/app/service/photo_approval.py @@ -66,7 +66,7 @@ async def decide( async def expire_stale(self, timeout_days: int) -> int: count = 0 - async for _ in self._approval_querier.expire_stale_approvals(timeout_days=timeout_days): + async for _ in self._approval_querier.expire_stale_approvals(dollar_1=timeout_days): count += 1 if count: logger.info("Auto-expired %d stale pending photo(s)", count) diff --git a/app/service/stats.py b/app/service/stats.py new file mode 100644 index 00000000..b1b9f740 --- /dev/null +++ b/app/service/stats.py @@ -0,0 +1,77 @@ +from datetime import datetime, timezone +import uuid +from typing import TYPE_CHECKING +from app.schema.response.web.stats import ( + AdminStatsResponse, DriveUsageResponse, + ProcessingLoadResponse, AlertResponse, AlertItem +) + +if TYPE_CHECKING: + from db.generated.stats import AsyncQuerier + +class StatsService: + def __init__(self, querier: "AsyncQuerier"): + self.q = querier + + async def get_dashboard_stats(self) -> AdminStatsResponse: + active_events = await self.q.get_active_events_count() + photos = await self.q.get_total_photos_uploaded() + metrics = await self.q.get_processing_job_metrics() + + return AdminStatsResponse( + active_events=active_events or 0, + photos_uploaded=photos or 0, + processed_photos=metrics.completed_count if metrics else 0, + queue_size=metrics.pending_count if metrics else 0, + timestamp=datetime.now(timezone.utc) + ) + + async def get_processing_load(self) -> ProcessingLoadResponse: + metrics = await self.q.get_processing_job_metrics() + if not metrics: + return ProcessingLoadResponse(completed=0.0, processing=0.0, queued=0.0) + + total = metrics.completed_count + metrics.running_count + metrics.pending_count + + if total == 0: + return ProcessingLoadResponse(completed=0.0, processing=0.0, queued=0.0) + + return ProcessingLoadResponse( + completed=round((metrics.completed_count / total) * 100, 1), + processing=round((metrics.running_count / total) * 100, 1), + queued=round((metrics.pending_count / total) * 100, 1) + ) + + async def get_storage_usage(self) -> DriveUsageResponse: + used_bytes = await self.q.get_total_storage_bytes() + # Mock d'un total de 1TB (1000 Go) pour l'affichage Frontend + total_bytes = 1000 * 1024 * 1024 * 1024 + + return DriveUsageResponse( + used_bytes=used_bytes or 0, + total_bytes=total_bytes, + timestamp=datetime.now(timezone.utc) + ) + + async def get_staff_alerts(self, staff_id: uuid.UUID) -> AlertResponse: + db_alerts = [a async for a in self.q.get_recent_staff_alerts(staff_user_id=staff_id)] + unread_count = await self.q.get_unread_staff_alerts_count(staff_user_id=staff_id) + + alerts = [] + for a in db_alerts: + # Assuming payload is a dict with title and message + payload = a.payload or {} + alerts.append(AlertItem( + id=str(a.id), + type=a.type, + title=payload.get("title", "Notification"), + message=payload.get("message", "No message provided"), + created_at=a.created_at, + is_read=a.read_at is not None + )) + + return AlertResponse( + alerts=alerts, + unread_count=unread_count or 0, + timestamp=datetime.now(timezone.utc) + ) diff --git a/db/generated/audit.py b/db/generated/audit.py index 47bf3f4e..b5cdd565 100644 --- a/db/generated/audit.py +++ b/db/generated/audit.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: audit.sql import dataclasses import datetime diff --git a/db/generated/devices.py b/db/generated/devices.py index e90ebdd9..4f744d18 100644 --- a/db/generated/devices.py +++ b/db/generated/devices.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: devices.sql import dataclasses from typing import Any, AsyncIterator, Optional diff --git a/db/generated/eventParticipant.py b/db/generated/eventParticipant.py index 0fcc26c1..0e6fd9cb 100644 --- a/db/generated/eventParticipant.py +++ b/db/generated/eventParticipant.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: eventParticipant.sql import dataclasses import datetime diff --git a/db/generated/events.py b/db/generated/events.py index 1ac89996..0395bfda 100644 --- a/db/generated/events.py +++ b/db/generated/events.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: events.sql import dataclasses import datetime diff --git a/db/generated/models.py b/db/generated/models.py index 79432143..07418cb1 100644 --- a/db/generated/models.py +++ b/db/generated/models.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 import dataclasses import datetime import enum diff --git a/db/generated/notifications.py b/db/generated/notifications.py index 3166cd36..543158db 100644 --- a/db/generated/notifications.py +++ b/db/generated/notifications.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: notifications.sql from typing import Any, AsyncIterator, Optional import uuid diff --git a/db/generated/photo_approvals.py b/db/generated/photo_approvals.py index d3776c39..fc22ec74 100644 --- a/db/generated/photo_approvals.py +++ b/db/generated/photo_approvals.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: photo_approvals.sql from typing import AsyncIterator, Optional import uuid @@ -11,11 +11,23 @@ from db.generated import models +CREATE_PHOTO_APPROVAL = """-- name: create_photo_approval \\:one +INSERT INTO photo_approvals ( + photo_id, + user_id, + decision +) VALUES ( + :p1, :p2, :p3 +) +RETURNING id, photo_id, user_id, decision, decided_at +""" + + EXPIRE_STALE_APPROVALS = """-- name: expire_stale_approvals \\:many WITH stale_photos AS ( SELECT id FROM photos WHERE status = 'pending' - AND created_at < now() - make_interval(days => :p1::int) + AND created_at < now() - make_interval(days => :p1\\:\\:int) ), _update_approvals AS ( UPDATE photo_approvals @@ -30,18 +42,6 @@ """ -CREATE_PHOTO_APPROVAL = """-- name: create_photo_approval \\:one -INSERT INTO photo_approvals ( - photo_id, - user_id, - decision -) VALUES ( - :p1, :p2, :p3 -) -RETURNING id, photo_id, user_id, decision, decided_at -""" - - GET_PHOTO_APPROVALS_BY_PHOTO_ID = """-- name: get_photo_approvals_by_photo_id \\:many SELECT id, photo_id, user_id, decision, decided_at FROM photo_approvals WHERE photo_id = :p1 """ @@ -68,11 +68,6 @@ class AsyncQuerier: def __init__(self, conn: sqlalchemy.ext.asyncio.AsyncConnection): self._conn = conn - async def expire_stale_approvals(self, *, timeout_days: int) -> AsyncIterator[uuid.UUID]: - result = await self._conn.stream(sqlalchemy.text(EXPIRE_STALE_APPROVALS), {"p1": timeout_days}) - async for row in result: - yield row[0] - async def create_photo_approval(self, *, photo_id: uuid.UUID, user_id: uuid.UUID, decision: str) -> Optional[models.PhotoApproval]: row = (await self._conn.execute(sqlalchemy.text(CREATE_PHOTO_APPROVAL), {"p1": photo_id, "p2": user_id, "p3": decision})).first() if row is None: @@ -85,6 +80,11 @@ async def create_photo_approval(self, *, photo_id: uuid.UUID, user_id: uuid.UUID decided_at=row[4], ) + async def expire_stale_approvals(self, *, dollar_1: int) -> AsyncIterator[uuid.UUID]: + result = await self._conn.stream(sqlalchemy.text(EXPIRE_STALE_APPROVALS), {"p1": dollar_1}) + async for row in result: + yield row[0] + async def get_photo_approvals_by_photo_id(self, *, photo_id: uuid.UUID) -> AsyncIterator[models.PhotoApproval]: result = await self._conn.stream(sqlalchemy.text(GET_PHOTO_APPROVALS_BY_PHOTO_ID), {"p1": photo_id}) async for row in result: diff --git a/db/generated/photo_faces.py b/db/generated/photo_faces.py index 6578ef8d..ae023b9f 100644 --- a/db/generated/photo_faces.py +++ b/db/generated/photo_faces.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: photo_faces.sql import dataclasses from typing import Any, Optional diff --git a/db/generated/photos.py b/db/generated/photos.py index 2c14a107..f2796c6c 100644 --- a/db/generated/photos.py +++ b/db/generated/photos.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: photos.sql import dataclasses import datetime diff --git a/db/generated/processing_jobs.py b/db/generated/processing_jobs.py index 0cde67b8..beeb0ef5 100644 --- a/db/generated/processing_jobs.py +++ b/db/generated/processing_jobs.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: processing_jobs.sql from typing import Any, Optional import uuid @@ -26,11 +26,11 @@ """ -UPDATE_PROCESSING_JOB_STATUS = """-- name: update_processing_job_status \:one +UPDATE_PROCESSING_JOB_STATUS = """-- name: update_processing_job_status \\:one UPDATE processing_jobs SET status = :p2, attempts = attempts + 1, - completed_at = CASE WHEN :p2 IN ('completed'::processing_job_status, 'failed'::processing_job_status) THEN now() ELSE completed_at END + completed_at = CASE WHEN :p2 IN ('completed'\\:\\:processing_job_status, 'failed'\\:\\:processing_job_status) THEN now() ELSE completed_at END WHERE id = :p1 RETURNING id, photo_id, job_type, status, attempts, created_at, completed_at """ diff --git a/db/generated/session.py b/db/generated/session.py index bc7b4279..ee803225 100644 --- a/db/generated/session.py +++ b/db/generated/session.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: session.sql import dataclasses import datetime diff --git a/db/generated/staff_drive_connections.py b/db/generated/staff_drive_connections.py index 941e8b7b..2394bc7d 100644 --- a/db/generated/staff_drive_connections.py +++ b/db/generated/staff_drive_connections.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: staff_drive_connections.sql import dataclasses import datetime diff --git a/db/generated/staff_notifications.py b/db/generated/staff_notifications.py index 1c285f8a..cd50adde 100644 --- a/db/generated/staff_notifications.py +++ b/db/generated/staff_notifications.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: staff_notifications.sql from typing import Any, AsyncIterator, Optional import uuid diff --git a/db/generated/stats.py b/db/generated/stats.py new file mode 100644 index 00000000..690bf209 --- /dev/null +++ b/db/generated/stats.py @@ -0,0 +1,107 @@ +# Code generated by sqlc. DO NOT EDIT. +# versions: +# sqlc v1.31.1 +# source: stats.sql +import dataclasses +from typing import AsyncIterator, Optional +import uuid + +import sqlalchemy +import sqlalchemy.ext.asyncio + +from db.generated import models + + +GET_ACTIVE_EVENTS_COUNT = """-- name: get_active_events_count \\:one +SELECT COUNT(*) FROM events WHERE status = 'scheduled' +""" + + +GET_PROCESSING_JOB_METRICS = """-- name: get_processing_job_metrics \\:one +SELECT + COUNT(*) FILTER (WHERE status = 'completed')\\:\\:int AS completed_count, + COUNT(*) FILTER (WHERE status = 'running')\\:\\:int AS running_count, + COUNT(*) FILTER (WHERE status = 'pending')\\:\\:int AS pending_count +FROM processing_jobs +""" + + +@dataclasses.dataclass() +class GetProcessingJobMetricsRow: + completed_count: int + running_count: int + pending_count: int + + +GET_RECENT_STAFF_ALERTS = """-- name: get_recent_staff_alerts \\:many +SELECT id, staff_user_id, type, payload, read_at, created_at FROM staff_notifications +WHERE staff_user_id = :p1 +ORDER BY created_at DESC LIMIT 10 +""" + + +GET_TOTAL_PHOTOS_UPLOADED = """-- name: get_total_photos_uploaded \\:one +SELECT COUNT(*) FROM photos +""" + + +GET_TOTAL_STORAGE_BYTES = """-- name: get_total_storage_bytes \\:one +SELECT COALESCE(SUM(size_bytes), 0)\\:\\:bigint FROM upload_request_photos +""" + + +GET_UNREAD_STAFF_ALERTS_COUNT = """-- name: get_unread_staff_alerts_count \\:one +SELECT COUNT(*) FROM staff_notifications +WHERE staff_user_id = :p1 AND read_at IS NULL +""" + + +class AsyncQuerier: + def __init__(self, conn: sqlalchemy.ext.asyncio.AsyncConnection): + self._conn = conn + + async def get_active_events_count(self) -> Optional[int]: + row = (await self._conn.execute(sqlalchemy.text(GET_ACTIVE_EVENTS_COUNT))).first() + if row is None: + return None + return row[0] + + async def get_processing_job_metrics(self) -> Optional[GetProcessingJobMetricsRow]: + row = (await self._conn.execute(sqlalchemy.text(GET_PROCESSING_JOB_METRICS))).first() + if row is None: + return None + return GetProcessingJobMetricsRow( + completed_count=row[0], + running_count=row[1], + pending_count=row[2], + ) + + async def get_recent_staff_alerts(self, *, staff_user_id: uuid.UUID) -> AsyncIterator[models.StaffNotification]: + result = await self._conn.stream(sqlalchemy.text(GET_RECENT_STAFF_ALERTS), {"p1": staff_user_id}) + async for row in result: + yield models.StaffNotification( + id=row[0], + staff_user_id=row[1], + type=row[2], + payload=row[3], + read_at=row[4], + created_at=row[5], + ) + + async def get_total_photos_uploaded(self) -> Optional[int]: + row = (await self._conn.execute(sqlalchemy.text(GET_TOTAL_PHOTOS_UPLOADED))).first() + if row is None: + return None + return row[0] + + async def get_total_storage_bytes(self) -> Optional[int]: + row = (await self._conn.execute(sqlalchemy.text(GET_TOTAL_STORAGE_BYTES))).first() + if row is None: + return None + return row[0] + + async def get_unread_staff_alerts_count(self, *, staff_user_id: uuid.UUID) -> Optional[int]: + row = (await self._conn.execute(sqlalchemy.text(GET_UNREAD_STAFF_ALERTS_COUNT), {"p1": staff_user_id})).first() + if row is None: + return None + return row[0] diff --git a/db/generated/stuff_user.py b/db/generated/stuff_user.py index 13375653..d5410785 100644 --- a/db/generated/stuff_user.py +++ b/db/generated/stuff_user.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: stuff_user.sql import dataclasses from typing import Any, AsyncIterator, Optional diff --git a/db/generated/upload_request_groups.py b/db/generated/upload_request_groups.py index dacf93df..039b1f05 100644 --- a/db/generated/upload_request_groups.py +++ b/db/generated/upload_request_groups.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: upload_request_groups.sql import dataclasses from typing import Any, AsyncIterator, Optional diff --git a/db/generated/upload_request_photos.py b/db/generated/upload_request_photos.py index 2180eabb..1cd3ebb4 100644 --- a/db/generated/upload_request_photos.py +++ b/db/generated/upload_request_photos.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: upload_request_photos.sql import dataclasses import datetime diff --git a/db/generated/upload_requests.py b/db/generated/upload_requests.py index db4887e2..b0da8bb0 100644 --- a/db/generated/upload_requests.py +++ b/db/generated/upload_requests.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: upload_requests.sql import dataclasses from typing import Any, AsyncIterator, Optional diff --git a/db/generated/user.py b/db/generated/user.py index 674d4b4a..d0ab8152 100644 --- a/db/generated/user.py +++ b/db/generated/user.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: user.sql import dataclasses from typing import Any, AsyncIterator, Optional diff --git a/db/queries/stats.sql b/db/queries/stats.sql new file mode 100644 index 00000000..fd4eb7e0 --- /dev/null +++ b/db/queries/stats.sql @@ -0,0 +1,24 @@ +-- name: GetActiveEventsCount :one +SELECT COUNT(*) FROM events WHERE status = 'scheduled'; + +-- name: GetTotalPhotosUploaded :one +SELECT COUNT(*) FROM photos; + +-- name: GetProcessingJobMetrics :one +SELECT + COUNT(*) FILTER (WHERE status = 'completed')::int AS completed_count, + COUNT(*) FILTER (WHERE status = 'running')::int AS running_count, + COUNT(*) FILTER (WHERE status = 'pending')::int AS pending_count +FROM processing_jobs; + +-- name: GetTotalStorageBytes :one +SELECT COALESCE(SUM(size_bytes), 0)::bigint FROM upload_request_photos; + +-- name: GetRecentStaffAlerts :many +SELECT * FROM staff_notifications +WHERE staff_user_id = $1 +ORDER BY created_at DESC LIMIT 10; + +-- name: GetUnreadStaffAlertsCount :one +SELECT COUNT(*) FROM staff_notifications +WHERE staff_user_id = $1 AND read_at IS NULL; diff --git a/tests/e2e/test_stats_endpoint.py b/tests/e2e/test_stats_endpoint.py new file mode 100644 index 00000000..f16ca7b0 --- /dev/null +++ b/tests/e2e/test_stats_endpoint.py @@ -0,0 +1,49 @@ +import pytest +import uuid +from fastapi.testclient import TestClient +from datetime import datetime, timezone +from app.main import app +from app.deps.cookie_auth import require_admin_staff +from db.generated.models import StaffUser, StaffRole +from typing import Generator + +@pytest.fixture(scope="module") +def client() -> Generator[TestClient, None, None]: + # Override the dependency to bypass cookie auth + mock_admin = StaffUser( + id=uuid.uuid4(), + email="test_admin@multai.com", + role=StaffRole.ADMIN, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + password="hashed_password" + ) + app.dependency_overrides[require_admin_staff] = lambda: mock_admin + + # Using 'with' triggers the FastAPI lifespan (initializes Redis and DB pools) + with TestClient(app) as c: + yield c + +def test_dashboard_stats(client: TestClient) -> None: + resp = client.get("/admin/stats/dashboard") + assert resp.status_code == 200, f"Expected 200 but got {resp.status_code}: {resp.text}" + data = resp.json() + assert "active_events" in data + +def test_processing_load(client: TestClient) -> None: + resp = client.get("/admin/stats/processing-load") + assert resp.status_code == 200, f"Expected 200 but got {resp.status_code}: {resp.text}" + data = resp.json() + assert "completed" in data + +def test_storage(client: TestClient) -> None: + resp = client.get("/admin/stats/storage") + assert resp.status_code == 200, f"Expected 200 but got {resp.status_code}: {resp.text}" + data = resp.json() + assert "used_bytes" in data + +def test_alerts(client: TestClient) -> None: + resp = client.get("/admin/stats/alerts") + assert resp.status_code == 200, f"Expected 200 but got {resp.status_code}: {resp.text}" + data = resp.json() + assert "alerts" in data