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:
parent
0888569639
commit
e665e82c25
4 changed files with 233 additions and 32 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
162
backend/routers/creator_dashboard.py
Normal file
162
backend/routers/creator_dashboard.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue