- 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>
379 lines
12 KiB
Python
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,
|
|
}
|