"""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), )