"""Analytics endpoints for admin dashboard.""" import uuid from datetime import datetime, timedelta, timezone from typing import Optional from fastapi import APIRouter, Depends, Query from sqlalchemy import func, select, and_ from sqlalchemy.ext.asyncio import AsyncSession from models.sql.presentation import PresentationModel from models.sql.user import UserModel from models.sql.job import JobModel from services.access_service import get_accessible_client_ids from services.database import get_async_session from utils.auth_dependencies import require_client_admin ANALYTICS_ROUTER = APIRouter(tags=["Analytics"]) async def _resolve_client_filter( client_id: Optional[uuid.UUID], user: UserModel, session: AsyncSession, ): """Return SQLAlchemy filter for presentation client scoping. super_admin with no filter → no restriction (sees all including NULL client_id). client_admin → scoped to their accessible clients. Explicit client_id → filter to that client only. """ if client_id: return PresentationModel.client_id == client_id if user.role == "super_admin": # Super admin sees everything — no client filter needed return None cids = await get_accessible_client_ids(user, session) if not cids: # No accessible clients — return impossible condition return PresentationModel.client_id == None # noqa: E711 if len(cids) == 1: return PresentationModel.client_id == cids[0] return PresentationModel.client_id.in_(cids) @ANALYTICS_ROUTER.get("/analytics/overview") async def analytics_overview( client_id: Optional[uuid.UUID] = Query(None), current_user: UserModel = Depends(require_client_admin), session: AsyncSession = Depends(get_async_session), ): """Aggregated stats: total presentations, active users, etc.""" now = datetime.now(timezone.utc) month_ago = now - timedelta(days=30) week_ago = now - timedelta(days=7) cf = await _resolve_client_filter(client_id, current_user, session) base_filters = [PresentationModel.deleted_at.is_(None)] if cf is not None: base_filters.append(cf) base = select(PresentationModel).where(and_(*base_filters)) # Total presentations total_q = select(func.count()).select_from(base.subquery()) total = (await session.execute(total_q)).scalar() or 0 # This month month_q = select(func.count()).select_from( base.where(PresentationModel.created_at >= month_ago).subquery() ) this_month = (await session.execute(month_q)).scalar() or 0 # This week week_q = select(func.count()).select_from( base.where(PresentationModel.created_at >= week_ago).subquery() ) this_week = (await session.execute(week_q)).scalar() or 0 # Active users (last 30 days) active_filters = [ PresentationModel.deleted_at.is_(None), PresentationModel.created_at >= month_ago, PresentationModel.owner_id.isnot(None), ] if cf is not None: active_filters.append(cf) active_q = select(func.count(func.distinct(PresentationModel.owner_id))).where( and_(*active_filters) ) active_users = (await session.execute(active_q)).scalar() or 0 # Approval rate reviewed_filters = [ PresentationModel.deleted_at.is_(None), PresentationModel.status.in_(["approved", "in_review"]), ] if cf is not None: reviewed_filters.append(cf) reviewed_q = select(func.count()).where(and_(*reviewed_filters)) approved_filters = [ PresentationModel.deleted_at.is_(None), PresentationModel.status == "approved", ] if cf is not None: approved_filters.append(cf) approved_q = select(func.count()).where(and_(*approved_filters)) reviewed = (await session.execute(reviewed_q)).scalar() or 0 approved = (await session.execute(approved_q)).scalar() or 0 approval_rate = round((approved / reviewed * 100) if reviewed > 0 else 0, 1) return { "total_presentations": total, "this_month": this_month, "this_week": this_week, "active_users": active_users, "approval_rate": approval_rate, } @ANALYTICS_ROUTER.get("/analytics/usage") async def analytics_usage( client_id: Optional[uuid.UUID] = Query(None), days: int = Query(30, ge=7, le=90), current_user: UserModel = Depends(require_client_admin), session: AsyncSession = Depends(get_async_session), ): """Usage metrics: decks per day time series, top users.""" now = datetime.now(timezone.utc) since = now - timedelta(days=days) cf = await _resolve_client_filter(client_id, current_user, session) # Decks per day daily_filters = [ PresentationModel.deleted_at.is_(None), PresentationModel.created_at >= since, ] if cf is not None: daily_filters.append(cf) daily_q = ( select( func.date(PresentationModel.created_at).label("day"), func.count().label("count"), ) .where(and_(*daily_filters)) .group_by(func.date(PresentationModel.created_at)) .order_by(func.date(PresentationModel.created_at)) ) daily_result = await session.execute(daily_q) daily = [{"date": str(row.day), "count": row.count} for row in daily_result] # Top users top_filters = [ PresentationModel.deleted_at.is_(None), PresentationModel.owner_id.isnot(None), PresentationModel.created_at >= since, ] if cf is not None: top_filters.append(cf) top_users_q = ( select( PresentationModel.owner_id, func.count().label("count"), ) .where(and_(*top_filters)) .group_by(PresentationModel.owner_id) .order_by(func.count().desc()) .limit(10) ) top_result = await session.execute(top_users_q) top_users = [ {"user_id": str(row.owner_id), "count": row.count} for row in top_result ] return { "daily": daily, "top_users": top_users, } @ANALYTICS_ROUTER.get("/analytics/quality") async def analytics_quality( client_id: Optional[uuid.UUID] = Query(None), current_user: UserModel = Depends(require_client_admin), session: AsyncSession = Depends(get_async_session), ): """Quality metrics: status distribution, average comments.""" cf = await _resolve_client_filter(client_id, current_user, session) base_filter = [PresentationModel.deleted_at.is_(None)] if cf is not None: base_filter.append(cf) # Status distribution status_q = ( select( PresentationModel.status, func.count().label("count"), ) .where(and_(*base_filter)) .group_by(PresentationModel.status) ) status_result = await session.execute(status_q) status_dist = {row.status or "draft": row.count for row in status_result} # Presentations with comments with_comments_q = select(func.count()).where( and_( *base_filter, PresentationModel.review_comment.isnot(None), ) ) with_comments = (await session.execute(with_comments_q)).scalar() or 0 return { "status_distribution": status_dist, "presentations_with_comments": with_comments, } @ANALYTICS_ROUTER.get("/analytics/performance") async def analytics_performance( client_id: Optional[uuid.UUID] = Query(None), current_user: UserModel = Depends(require_client_admin), session: AsyncSession = Depends(get_async_session), ): """Performance metrics: job durations and error rates.""" # Build job filter job_filter = [] if client_id: job_filter.append(JobModel.client_id == client_id) elif current_user.role != "super_admin": cids = await get_accessible_client_ids(current_user, session) if cids: if len(cids) == 1: job_filter.append(JobModel.client_id == cids[0]) else: job_filter.append(JobModel.client_id.in_(cids)) # Job status distribution job_status_q = ( select( JobModel.status, func.count().label("count"), ) .where(and_(*job_filter)) if job_filter else select( JobModel.status, func.count().label("count"), ) ).group_by(JobModel.status) job_result = await session.execute(job_status_q) job_status = {row.status: row.count for row in job_result} # Average generation time (completed jobs only) avg_base = [ JobModel.status == "completed", JobModel.started_at.isnot(None), JobModel.completed_at.isnot(None), ] if job_filter: avg_base.extend(job_filter) avg_time_q = select( func.avg( func.extract("epoch", JobModel.completed_at) - func.extract("epoch", JobModel.started_at) ) ).where(and_(*avg_base)) avg_seconds = (await session.execute(avg_time_q)).scalar() avg_generation_time = round(avg_seconds, 1) if avg_seconds else None # Error rate total_jobs = sum(job_status.values()) if job_status else 0 failed_jobs = job_status.get("failed", 0) error_rate = round((failed_jobs / total_jobs * 100) if total_jobs > 0 else 0, 1) return { "job_status_distribution": job_status, "avg_generation_time_seconds": avg_generation_time, "error_rate": error_rate, "total_jobs": total_jobs, } @ANALYTICS_ROUTER.get("/analytics/ai-usage") async def analytics_ai_usage( client_id: Optional[uuid.UUID] = Query(None), days: int = Query(30, ge=7, le=90), current_user: UserModel = Depends(require_client_admin), session: AsyncSession = Depends(get_async_session), ): """AI model usage: total calls, tokens consumed, breakdown by provider.""" from models.sql.ai_usage import AIUsageModel since = datetime.now(timezone.utc) - timedelta(days=days) base_filter = [AIUsageModel.created_at >= since] if client_id: base_filter.append(AIUsageModel.client_id == client_id) elif current_user.role != "super_admin": cids = await get_accessible_client_ids(current_user, session) if cids: if len(cids) == 1: base_filter.append(AIUsageModel.client_id == cids[0]) else: base_filter.append(AIUsageModel.client_id.in_(cids)) # Totals totals_q = select( func.count().label("total_calls"), func.coalesce(func.sum(AIUsageModel.input_tokens), 0).label("total_input_tokens"), func.coalesce(func.sum(AIUsageModel.output_tokens), 0).label("total_output_tokens"), func.coalesce(func.sum(AIUsageModel.total_tokens), 0).label("total_tokens"), ).where(and_(*base_filter)) totals = (await session.execute(totals_q)).one() # By provider by_provider_q = ( select( AIUsageModel.provider, func.count().label("calls"), func.coalesce(func.sum(AIUsageModel.total_tokens), 0).label("tokens"), ) .where(and_(*base_filter)) .group_by(AIUsageModel.provider) ) provider_result = await session.execute(by_provider_q) by_provider = [ {"provider": row.provider, "calls": row.calls, "tokens": row.tokens} for row in provider_result ] # By model by_model_q = ( select( AIUsageModel.model, func.count().label("calls"), func.coalesce(func.sum(AIUsageModel.total_tokens), 0).label("tokens"), ) .where(and_(*base_filter)) .group_by(AIUsageModel.model) .order_by(func.count().desc()) .limit(10) ) model_result = await session.execute(by_model_q) by_model = [ {"model": row.model, "calls": row.calls, "tokens": row.tokens} for row in model_result ] # Daily usage daily_q = ( select( func.date(AIUsageModel.created_at).label("day"), func.count().label("calls"), func.coalesce(func.sum(AIUsageModel.total_tokens), 0).label("tokens"), ) .where(and_(*base_filter)) .group_by(func.date(AIUsageModel.created_at)) .order_by(func.date(AIUsageModel.created_at)) ) daily_result = await session.execute(daily_q) daily = [ {"date": str(row.day), "calls": row.calls, "tokens": row.tokens} for row in daily_result ] return { "total_calls": totals.total_calls, "total_input_tokens": totals.total_input_tokens, "total_output_tokens": totals.total_output_tokens, "total_tokens": totals.total_tokens, "by_provider": by_provider, "by_model": by_model, "daily": daily, }