Spaces:
Sleeping
Sleeping
| """ | |
| Admin analytics dashboard API. | |
| Provides global system stats for internal monitoring. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| from datetime import datetime, timedelta, timezone | |
| from typing import Annotated | |
| from fastapi import APIRouter, Depends, HTTPException, status | |
| from pydantic import BaseModel | |
| from sqlalchemy import desc, func, select | |
| from sqlalchemy.ext.asyncio import AsyncSession | |
| from app.database import get_db | |
| from app.dependencies import require_admin | |
| from app.models.screening import Screening | |
| from app.models.user import User | |
| log = logging.getLogger("anemialens.admin") | |
| router = APIRouter(prefix="/api/admin", tags=["admin"]) | |
| class AdminTrendPoint(BaseModel): | |
| date: str | |
| label: str | |
| scans: int | |
| blocked: int | |
| class AdminRecentScreeningItem(BaseModel): | |
| uid: str | |
| triage_band: str | |
| triage_label: str | |
| screening_label: str | None | |
| confidence: float | None | |
| predicted_hemoglobin: float | None | |
| processing_time_ms: float | |
| guidance_source: str | |
| processing_path: str | |
| headline: str | None | |
| blocked: bool | |
| created_at: str | |
| class AdminStatsResponse(BaseModel): | |
| total_users: int | |
| active_users: int | |
| total_scans: int | |
| scans_by_band: dict[str, int] | |
| avg_processing_time_ms: float | |
| blocked_scans: int | |
| blocked_rate: float | |
| pro_users: int | |
| free_users: int | |
| pro_adoption_rate: float | |
| avg_confidence: float | |
| avg_risk: float | |
| recent_scans_24h: int | |
| scans_last_7_days: list[AdminTrendPoint] | |
| processing_paths: dict[str, int] | |
| guidance_sources: dict[str, int] | |
| recent_screenings: list[AdminRecentScreeningItem] | |
| class AdminUserItem(BaseModel): | |
| uid: str | |
| email: str | |
| full_name: str | None | |
| role: str | |
| subscription_tier: str | |
| scan_count: int | |
| is_active: bool | |
| created_at: str | |
| class AdminUsersResponse(BaseModel): | |
| users: list[AdminUserItem] | |
| total: int | |
| class TogglePlanRequest(BaseModel): | |
| subscription_tier: str # "free" | "pro" | |
| def _safe_ratio(numerator: int, denominator: int) -> float: | |
| return round(numerator / denominator, 4) if denominator else 0.0 | |
| def _normalise_count_map(rows: list[tuple[str | None, int]]) -> dict[str, int]: | |
| counts: dict[str, int] = {} | |
| for key, count in rows: | |
| label = key or "unknown" | |
| counts[label] = int(count or 0) | |
| return counts | |
| def _to_iso(dt: datetime) -> str: | |
| return dt.astimezone(timezone.utc).isoformat() if dt.tzinfo else dt.isoformat() | |
| async def get_system_stats( | |
| db: Annotated[AsyncSession, Depends(get_db)] | |
| ) -> AdminStatsResponse: | |
| now = datetime.now(timezone.utc) | |
| since_24h = now - timedelta(hours=24) | |
| since_7d = now - timedelta(days=6) | |
| users_result = await db.execute(select(func.count()).select_from(User)) | |
| total_users = int(users_result.scalar() or 0) | |
| active_users_result = await db.execute( | |
| select(func.count()).select_from(User).where(User.is_active.is_(True)) | |
| ) | |
| active_users = int(active_users_result.scalar() or 0) | |
| pro_result = await db.execute( | |
| select(func.count()).select_from(User).where(User.subscription_tier == "pro") | |
| ) | |
| pro_users = int(pro_result.scalar() or 0) | |
| scans_result = await db.execute(select(func.count()).select_from(Screening)) | |
| total_scans = int(scans_result.scalar() or 0) | |
| blocked_result = await db.execute( | |
| select(func.count()).select_from(Screening).where(Screening.blocked.is_(True)) | |
| ) | |
| blocked_scans = int(blocked_result.scalar() or 0) | |
| scans_24h_result = await db.execute( | |
| select(func.count()) | |
| .select_from(Screening) | |
| .where(Screening.created_at >= since_24h) | |
| ) | |
| recent_scans_24h = int(scans_24h_result.scalar() or 0) | |
| bands_result = await db.execute( | |
| select(Screening.triage_band, func.count(Screening.id)).group_by(Screening.triage_band) | |
| ) | |
| scans_by_band = _normalise_count_map(list(bands_result.all())) | |
| time_result = await db.execute( | |
| select(func.avg(Screening.processing_time_ms)).where(Screening.processing_time_ms > 0) | |
| ) | |
| avg_processing_time = float(time_result.scalar() or 0.0) | |
| confidence_result = await db.execute( | |
| select(func.avg(Screening.confidence)).where(Screening.confidence.is_not(None)) | |
| ) | |
| avg_confidence = float(confidence_result.scalar() or 0.0) | |
| risk_result = await db.execute( | |
| select(func.avg(Screening.anemia_risk)).where(Screening.anemia_risk.is_not(None)) | |
| ) | |
| avg_risk = float(risk_result.scalar() or 0.0) | |
| path_result = await db.execute( | |
| select(Screening.processing_path, func.count(Screening.id)).group_by(Screening.processing_path) | |
| ) | |
| processing_paths = _normalise_count_map(list(path_result.all())) | |
| guidance_result = await db.execute( | |
| select(Screening.guidance_source, func.count(Screening.id)).group_by(Screening.guidance_source) | |
| ) | |
| guidance_sources = _normalise_count_map(list(guidance_result.all())) | |
| recent_activity_result = await db.execute( | |
| select(Screening.created_at, Screening.blocked) | |
| .where(Screening.created_at >= since_7d) | |
| .order_by(Screening.created_at.asc()) | |
| ) | |
| trend_seed = { | |
| (now.date() - timedelta(days=offset)).isoformat(): {"scans": 0, "blocked": 0} | |
| for offset in range(6, -1, -1) | |
| } | |
| for created_at, blocked in recent_activity_result.all(): | |
| if created_at is None: | |
| continue | |
| day_key = ( | |
| created_at.astimezone(timezone.utc).date().isoformat() | |
| if created_at.tzinfo | |
| else created_at.date().isoformat() | |
| ) | |
| if day_key not in trend_seed: | |
| continue | |
| trend_seed[day_key]["scans"] += 1 | |
| trend_seed[day_key]["blocked"] += int(bool(blocked)) | |
| scans_last_7_days = [ | |
| AdminTrendPoint( | |
| date=day_key, | |
| label=datetime.fromisoformat(day_key).strftime("%a"), | |
| scans=payload["scans"], | |
| blocked=payload["blocked"], | |
| ) | |
| for day_key, payload in trend_seed.items() | |
| ] | |
| recent_screenings_result = await db.execute( | |
| select(Screening).order_by(desc(Screening.created_at)).limit(6) | |
| ) | |
| recent_screenings = recent_screenings_result.scalars().all() | |
| return AdminStatsResponse( | |
| total_users=total_users, | |
| active_users=active_users, | |
| total_scans=total_scans, | |
| scans_by_band=scans_by_band, | |
| avg_processing_time_ms=avg_processing_time, | |
| blocked_scans=blocked_scans, | |
| blocked_rate=_safe_ratio(blocked_scans, total_scans), | |
| pro_users=pro_users, | |
| free_users=max(total_users - pro_users, 0), | |
| pro_adoption_rate=_safe_ratio(pro_users, total_users), | |
| avg_confidence=avg_confidence, | |
| avg_risk=avg_risk, | |
| recent_scans_24h=recent_scans_24h, | |
| scans_last_7_days=scans_last_7_days, | |
| processing_paths=processing_paths, | |
| guidance_sources=guidance_sources, | |
| recent_screenings=[ | |
| AdminRecentScreeningItem( | |
| uid=screening.uid, | |
| triage_band=screening.triage_band, | |
| triage_label=screening.triage_label, | |
| screening_label=screening.screening_label, | |
| confidence=screening.confidence, | |
| predicted_hemoglobin=screening.predicted_hemoglobin, | |
| processing_time_ms=screening.processing_time_ms, | |
| guidance_source=screening.guidance_source, | |
| processing_path=screening.processing_path, | |
| headline=screening.headline, | |
| blocked=screening.blocked, | |
| created_at=_to_iso(screening.created_at), | |
| ) | |
| for screening in recent_screenings | |
| ], | |
| ) | |
| async def list_users( | |
| db: Annotated[AsyncSession, Depends(get_db)], | |
| page: int = 1, | |
| page_size: int = 50, | |
| ) -> AdminUsersResponse: | |
| total_result = await db.execute(select(func.count()).select_from(User)) | |
| total = total_result.scalar() or 0 | |
| offset = (page - 1) * page_size | |
| result = await db.execute( | |
| select(User).order_by(desc(User.created_at)).offset(offset).limit(page_size) | |
| ) | |
| users = result.scalars().all() | |
| return AdminUsersResponse( | |
| users=[ | |
| AdminUserItem( | |
| uid=u.uid, | |
| email=u.email, | |
| full_name=u.full_name, | |
| role=u.role, | |
| subscription_tier=u.subscription_tier or "free", | |
| scan_count=u.scan_count, | |
| is_active=u.is_active, | |
| created_at=_to_iso(u.created_at), | |
| ) | |
| for u in users | |
| ], | |
| total=total, | |
| ) | |
| async def toggle_user_plan( | |
| user_uid: str, | |
| body: TogglePlanRequest, | |
| db: Annotated[AsyncSession, Depends(get_db)], | |
| ): | |
| if body.subscription_tier not in ("free", "pro"): | |
| raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid tier.") | |
| result = await db.execute(select(User).where(User.uid == user_uid)) | |
| user = result.scalar_one_or_none() | |
| if not user: | |
| raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found.") | |
| user.subscription_tier = body.subscription_tier | |
| await db.flush() | |
| log.info("Admin toggled %s -> %s", user.email, body.subscription_tier) | |
| return {"uid": user_uid, "subscription_tier": body.subscription_tier} | |
| async def toggle_user_active( | |
| user_uid: str, | |
| db: Annotated[AsyncSession, Depends(get_db)], | |
| ): | |
| result = await db.execute(select(User).where(User.uid == user_uid)) | |
| user = result.scalar_one_or_none() | |
| if not user: | |
| raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found.") | |
| user.is_active = not user.is_active | |
| await db.flush() | |
| log.info("Admin toggled active for %s -> %s", user.email, user.is_active) | |
| return {"uid": user_uid, "is_active": user.is_active} | |