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