feat: Added GET /api/v1/creator/dashboard returning video_count, techni…

- "backend/routers/creator_dashboard.py"
- "backend/schemas.py"
- "backend/main.py"
- "alembic/versions/016_add_users_and_invite_codes.py"

GSD-Task: S02/T01
This commit is contained in:
jlightner 2026-04-04 00:09:19 +00:00
parent 0888569639
commit e665e82c25
4 changed files with 233 additions and 32 deletions

View file

@ -4,8 +4,6 @@ Revision ID: 016_add_users_and_invite_codes
Revises: 015_add_creator_profile Revises: 015_add_creator_profile
""" """
from alembic import op from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision = "016_add_users_and_invite_codes" revision = "016_add_users_and_invite_codes"
down_revision = "015_add_creator_profile" down_revision = "015_add_creator_profile"
@ -14,37 +12,41 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
# Create user_role enum type # Use raw SQL to avoid SQLAlchemy's Enum double-creation bug with asyncpg
user_role_enum = sa.Enum("creator", "admin", name="user_role", create_constraint=True) op.execute("""
user_role_enum.create(op.get_bind(), checkfirst=True) DO $$ BEGIN
CREATE TYPE user_role AS ENUM ('creator', 'admin');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
""")
# Create users table op.execute("""
op.create_table( CREATE TABLE IF NOT EXISTS users (
"users", id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.func.gen_random_uuid()), email VARCHAR(255) NOT NULL UNIQUE,
sa.Column("email", sa.String(255), nullable=False, unique=True), hashed_password VARCHAR(255) NOT NULL,
sa.Column("hashed_password", sa.String(255), nullable=False), display_name VARCHAR(255) NOT NULL,
sa.Column("display_name", sa.String(255), nullable=False), role user_role NOT NULL DEFAULT 'creator',
sa.Column("role", user_role_enum, nullable=False, server_default="creator"), creator_id UUID REFERENCES creators(id) ON DELETE SET NULL,
sa.Column("creator_id", UUID(as_uuid=True), sa.ForeignKey("creators.id", ondelete="SET NULL"), nullable=True), is_active BOOLEAN NOT NULL DEFAULT TRUE,
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"), created_at TIMESTAMP NOT NULL DEFAULT now(),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), updated_at TIMESTAMP NOT NULL DEFAULT now()
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), )
) """)
# Create invite_codes table op.execute("""
op.create_table( CREATE TABLE IF NOT EXISTS invite_codes (
"invite_codes", id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.func.gen_random_uuid()), code VARCHAR(100) NOT NULL UNIQUE,
sa.Column("code", sa.String(100), nullable=False, unique=True), uses_remaining INTEGER NOT NULL DEFAULT 1,
sa.Column("uses_remaining", sa.Integer(), nullable=False, server_default="1"), created_by UUID REFERENCES users(id) ON DELETE SET NULL,
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), expires_at TIMESTAMP,
sa.Column("expires_at", sa.DateTime(), nullable=True), created_at TIMESTAMP NOT NULL DEFAULT now()
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), )
) """)
def downgrade() -> None: def downgrade() -> None:
op.drop_table("invite_codes") op.execute("DROP TABLE IF EXISTS invite_codes")
op.drop_table("users") op.execute("DROP TABLE IF EXISTS users")
sa.Enum(name="user_role").drop(op.get_bind(), checkfirst=True) op.execute("DROP TYPE IF EXISTS user_role")

View file

@ -12,7 +12,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from config import get_settings from config import get_settings
from routers import auth, consent, creators, health, ingest, pipeline, reports, search, stats, techniques, topics, videos from routers import auth, consent, creator_dashboard, creators, health, ingest, pipeline, reports, search, stats, techniques, topics, videos
def _setup_logging() -> None: def _setup_logging() -> None:
@ -80,6 +80,7 @@ app.include_router(health.router)
# Versioned API # Versioned API
app.include_router(auth.router, prefix="/api/v1") app.include_router(auth.router, prefix="/api/v1")
app.include_router(consent.router, prefix="/api/v1") app.include_router(consent.router, prefix="/api/v1")
app.include_router(creator_dashboard.router, prefix="/api/v1")
app.include_router(creators.router, prefix="/api/v1") app.include_router(creators.router, prefix="/api/v1")
app.include_router(ingest.router, prefix="/api/v1") app.include_router(ingest.router, prefix="/api/v1")
app.include_router(pipeline.router, prefix="/api/v1") app.include_router(pipeline.router, prefix="/api/v1")

View file

@ -0,0 +1,162 @@
"""Creator dashboard endpoint — authenticated analytics for a linked creator.
Returns aggregate counts (videos, technique pages, key moments, search
impressions) and content lists for the logged-in creator's dashboard.
"""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from auth import get_current_user
from database import get_session
from models import (
Creator,
KeyMoment,
SearchLog,
SourceVideo,
TechniquePage,
User,
)
from schemas import (
CreatorDashboardResponse,
CreatorDashboardTechnique,
CreatorDashboardVideo,
)
logger = logging.getLogger("chrysopedia.creator_dashboard")
router = APIRouter(prefix="/creator", tags=["creator-dashboard"])
@router.get("/dashboard", response_model=CreatorDashboardResponse)
async def get_creator_dashboard(
current_user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_session),
) -> CreatorDashboardResponse:
"""Return dashboard analytics for the authenticated creator.
Requires the user to have a linked creator_id. Returns 404 if the
user has no linked creator profile.
"""
if current_user.creator_id is None:
raise HTTPException(
status_code=404,
detail="No creator profile linked to this account",
)
creator_id = current_user.creator_id
# Verify creator exists (defensive — FK should guarantee this)
creator = (await db.execute(
select(Creator).where(Creator.id == creator_id)
)).scalar_one_or_none()
if creator is None:
logger.error("User %s has creator_id %s but creator row missing", current_user.id, creator_id)
raise HTTPException(
status_code=404,
detail="Linked creator profile not found",
)
# ── Aggregate counts ─────────────────────────────────────────────────
video_count = (await db.execute(
select(func.count()).select_from(SourceVideo)
.where(SourceVideo.creator_id == creator_id)
)).scalar() or 0
technique_count = (await db.execute(
select(func.count()).select_from(TechniquePage)
.where(TechniquePage.creator_id == creator_id)
)).scalar() or 0
key_moment_count = (await db.execute(
select(func.count()).select_from(KeyMoment)
.join(SourceVideo, KeyMoment.source_video_id == SourceVideo.id)
.where(SourceVideo.creator_id == creator_id)
)).scalar() or 0
# Search impressions: count distinct search_log rows where the query
# exactly matches (case-insensitive) any of this creator's technique titles.
search_impressions = (await db.execute(
select(func.count(func.distinct(SearchLog.id)))
.where(
select(TechniquePage.id)
.where(
TechniquePage.creator_id == creator_id,
func.lower(SearchLog.query) == func.lower(TechniquePage.title),
)
.correlate(SearchLog)
.exists()
)
)).scalar() or 0
# ── Content lists ────────────────────────────────────────────────────
# Techniques with per-page key moment count
km_count_sq = (
select(func.count(KeyMoment.id))
.where(KeyMoment.technique_page_id == TechniquePage.id)
.correlate(TechniquePage)
.scalar_subquery()
.label("key_moment_count")
)
technique_rows = (await db.execute(
select(
TechniquePage.title,
TechniquePage.slug,
TechniquePage.topic_category,
TechniquePage.created_at,
km_count_sq,
)
.where(TechniquePage.creator_id == creator_id)
.order_by(TechniquePage.created_at.desc())
)).all()
techniques = [
CreatorDashboardTechnique(
title=r.title,
slug=r.slug,
topic_category=r.topic_category,
created_at=r.created_at,
key_moment_count=r.key_moment_count or 0,
)
for r in technique_rows
]
# Videos
video_rows = (await db.execute(
select(
SourceVideo.filename,
SourceVideo.processing_status,
SourceVideo.created_at,
)
.where(SourceVideo.creator_id == creator_id)
.order_by(SourceVideo.created_at.desc())
)).all()
videos = [
CreatorDashboardVideo(
filename=r.filename,
processing_status=r.processing_status.value if hasattr(r.processing_status, 'value') else str(r.processing_status),
created_at=r.created_at,
)
for r in video_rows
]
logger.info(
"Dashboard loaded for creator %s: %d videos, %d techniques, %d moments, %d impressions",
creator_id, video_count, technique_count, key_moment_count, search_impressions,
)
return CreatorDashboardResponse(
video_count=video_count,
technique_count=technique_count,
key_moment_count=key_moment_count,
search_impressions=search_impressions,
techniques=techniques,
videos=videos,
)

View file

@ -621,3 +621,39 @@ class ConsentSummary(BaseModel):
kb_inclusion_granted: int = 0 kb_inclusion_granted: int = 0
training_usage_granted: int = 0 training_usage_granted: int = 0
public_display_granted: int = 0 public_display_granted: int = 0
# ── Creator Dashboard ────────────────────────────────────────────────────────
class CreatorDashboardTechnique(BaseModel):
"""Technique page summary for creator dashboard."""
title: str
slug: str
topic_category: str
created_at: datetime
key_moment_count: int = 0
class CreatorDashboardVideo(BaseModel):
"""Source video summary for creator dashboard."""
filename: str
processing_status: str
created_at: datetime
class CreatorDashboardStats(BaseModel):
"""Aggregate counts for dashboard header."""
video_count: int = 0
technique_count: int = 0
key_moment_count: int = 0
search_impressions: int = 0
class CreatorDashboardResponse(BaseModel):
"""Full creator dashboard payload."""
video_count: int = 0
technique_count: int = 0
key_moment_count: int = 0
search_impressions: int = 0
techniques: list[CreatorDashboardTechnique] = Field(default_factory=list)
videos: list[CreatorDashboardVideo] = Field(default_factory=list)