ppt-tool/backend/api/v1/admin/analytics_router.py
Vadym Samoilenko d3d1667a79 Phase 2: Admin panel, analytics, storage, template pipeline, multi-provider LLM
- Fix admin sidebar: remove duplicate Teams, add Storage nav item
- Analytics: client-scoped queries, super_admin sees all (including NULL client_id)
- Storage management: list/download/delete presentations with file metadata
- Settings page with brand config router
- AI usage tracking: new AIUsageModel, ai_usage_service, analytics endpoint
- Master deck → template bridge: _register_as_template creates TemplateModel
  + PresentationLayoutCodeModel so parsed layouts appear in template picker
- Multi-provider LLM vision in parser: Anthropic/Google/OpenAI with asyncio.to_thread
- Fix PPTX upload 400: accept by .pptx extension (browser sends octet-stream)
- Fix reparse FK violation: presentation_id=None for parse_master_deck jobs
- Worker job_timeout increased to 1800s for LLM-heavy master deck parsing
- PYTHONUNBUFFERED=1 in docker-compose worker for real-time log output
- Auth: clientId in /me response, dev-login cookie improvements
- Frontend: auth slice clientId, master-deck thumbnails, storage page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:39:34 +00:00

379 lines
12 KiB
Python

"""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,
}