Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion app/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions app/deps/cookie_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 2 additions & 0 deletions app/router/web/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
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)
router.include_router(event_router)
router.include_router(auth_routes)
router.include_router(audit_router)
router.include_router(users_router)
router.include_router(stats_router)
45 changes: 45 additions & 0 deletions app/router/web/stats.py
Original file line number Diff line number Diff line change
@@ -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)
35 changes: 35 additions & 0 deletions app/schema/response/web/stats.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion app/service/photo_approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
77 changes: 77 additions & 0 deletions app/service/stats.py
Original file line number Diff line number Diff line change
@@ -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)
)
2 changes: 1 addition & 1 deletion db/generated/audit.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion db/generated/devices.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion db/generated/eventParticipant.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion db/generated/events.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion db/generated/models.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion db/generated/notifications.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
38 changes: 19 additions & 19 deletions db/generated/photo_approvals.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
"""
Expand All @@ -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:
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion db/generated/photo_faces.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion db/generated/photos.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 3 additions & 3 deletions db/generated/processing_jobs.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
"""
Expand Down
2 changes: 1 addition & 1 deletion db/generated/session.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading