chrysopedia/backend/routers/creator_dashboard.py
jlightner b32fc5134b feat: Added GET /creator/transparency endpoint returning technique page…
- "backend/schemas.py"
- "backend/routers/creator_dashboard.py"

GSD-Task: S05/T01
2026-04-04 13:55:13 +00:00

320 lines
12 KiB
Python

"""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 sqlalchemy.orm import selectinload
from auth import get_current_user
from database import get_session
from models import (
Creator,
KeyMoment,
RelatedTechniqueLink,
SearchLog,
SourceVideo,
TechniquePage,
User,
)
from schemas import (
CreatorDashboardResponse,
CreatorDashboardTechnique,
CreatorDashboardVideo,
CreatorTransparencyResponse,
TransparencyKeyMoment,
TransparencyRelationship,
TransparencySourceVideo,
TransparencyTechnique,
)
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,
)
@router.get("/transparency", response_model=CreatorTransparencyResponse)
async def get_creator_transparency(
current_user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_session),
) -> CreatorTransparencyResponse:
"""Return all entities derived from the authenticated creator's content.
Shows technique pages, key moments, cross-reference relationships,
source videos, and aggregated topic tags — everything the AI pipeline
produced from this creator's uploads.
"""
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
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",
)
# ── Technique pages with key moment counts ───────────────────────────
technique_pages = (await db.execute(
select(TechniquePage)
.where(TechniquePage.creator_id == creator_id)
.options(
selectinload(TechniquePage.key_moments).selectinload(KeyMoment.source_video),
selectinload(TechniquePage.outgoing_links).selectinload(RelatedTechniqueLink.target_page),
selectinload(TechniquePage.incoming_links).selectinload(RelatedTechniqueLink.source_page),
)
.order_by(TechniquePage.created_at.desc())
)).scalars().all()
techniques = []
all_key_moments: list[TransparencyKeyMoment] = []
all_relationships: list[TransparencyRelationship] = []
all_tags: set[str] = set()
for tp in technique_pages:
techniques.append(TransparencyTechnique(
title=tp.title,
slug=tp.slug,
topic_category=tp.topic_category,
topic_tags=tp.topic_tags or [],
summary=(tp.summary or "")[:200],
created_at=tp.created_at,
key_moment_count=len(tp.key_moments),
))
# Collect tags
if tp.topic_tags:
all_tags.update(tp.topic_tags)
# Key moments from this technique page
for km in tp.key_moments:
all_key_moments.append(TransparencyKeyMoment(
title=km.title,
summary=km.summary,
content_type=km.content_type.value if hasattr(km.content_type, 'value') else str(km.content_type),
start_time=km.start_time,
end_time=km.end_time,
source_video_filename=km.source_video.filename if km.source_video else "",
technique_page_title=tp.title,
))
# Outgoing relationships
for link in tp.outgoing_links:
all_relationships.append(TransparencyRelationship(
relationship_type=link.relationship.value if hasattr(link.relationship, 'value') else str(link.relationship),
source_page_title=tp.title,
source_page_slug=tp.slug,
target_page_title=link.target_page.title if link.target_page else "",
target_page_slug=link.target_page.slug if link.target_page else "",
))
# Incoming relationships
for link in tp.incoming_links:
all_relationships.append(TransparencyRelationship(
relationship_type=link.relationship.value if hasattr(link.relationship, 'value') else str(link.relationship),
source_page_title=link.source_page.title if link.source_page else "",
source_page_slug=link.source_page.slug if link.source_page else "",
target_page_title=tp.title,
target_page_slug=tp.slug,
))
# ── Key moments not linked to a technique page ───────────────────────
# (moments from creator's videos that haven't been assigned to a page)
unlinked_moments = (await db.execute(
select(KeyMoment)
.join(SourceVideo, KeyMoment.source_video_id == SourceVideo.id)
.where(
SourceVideo.creator_id == creator_id,
KeyMoment.technique_page_id.is_(None),
)
.options(selectinload(KeyMoment.source_video))
)).scalars().all()
for km in unlinked_moments:
all_key_moments.append(TransparencyKeyMoment(
title=km.title,
summary=km.summary,
content_type=km.content_type.value if hasattr(km.content_type, 'value') else str(km.content_type),
start_time=km.start_time,
end_time=km.end_time,
source_video_filename=km.source_video.filename if km.source_video else "",
technique_page_title=None,
))
# ── Source videos ────────────────────────────────────────────────────
video_rows = (await db.execute(
select(SourceVideo)
.where(SourceVideo.creator_id == creator_id)
.order_by(SourceVideo.created_at.desc())
)).scalars().all()
source_videos = [
TransparencySourceVideo(
filename=v.filename,
processing_status=v.processing_status.value if hasattr(v.processing_status, 'value') else str(v.processing_status),
created_at=v.created_at,
)
for v in video_rows
]
logger.info(
"Transparency loaded for creator %s: %d techniques, %d moments, %d relationships, %d videos, %d tags",
creator_id, len(techniques), len(all_key_moments),
len(all_relationships), len(source_videos), len(all_tags),
)
return CreatorTransparencyResponse(
techniques=techniques,
key_moments=all_key_moments,
relationships=all_relationships,
source_videos=source_videos,
tags=sorted(all_tags),
)