"""Technique page endpoints — list and detail with eager-loaded relations.""" from __future__ import annotations import logging from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from database import get_session from models import Creator, KeyMoment, RelatedTechniqueLink, SourceVideo, TechniquePage, TechniquePageVersion from schemas import ( CreatorInfo, KeyMomentSummary, PaginatedResponse, RelatedLinkItem, TechniquePageDetail, TechniquePageRead, TechniquePageVersionDetail, TechniquePageVersionListResponse, TechniquePageVersionSummary, ) logger = logging.getLogger("chrysopedia.techniques") router = APIRouter(prefix="/techniques", tags=["techniques"]) @router.get("", response_model=PaginatedResponse) async def list_techniques( category: Annotated[str | None, Query()] = None, creator_slug: Annotated[str | None, Query()] = None, offset: Annotated[int, Query(ge=0)] = 0, limit: Annotated[int, Query(ge=1, le=100)] = 50, db: AsyncSession = Depends(get_session), ) -> PaginatedResponse: """List technique pages with optional category/creator filtering.""" stmt = select(TechniquePage) if category: stmt = stmt.where(TechniquePage.topic_category == category) if creator_slug: # Join to Creator to filter by slug stmt = stmt.join(Creator, TechniquePage.creator_id == Creator.id).where( Creator.slug == creator_slug ) # Count total before pagination from sqlalchemy import func count_stmt = select(func.count()).select_from(stmt.subquery()) count_result = await db.execute(count_stmt) total = count_result.scalar() or 0 stmt = stmt.order_by(TechniquePage.created_at.desc()).offset(offset).limit(limit) result = await db.execute(stmt) pages = result.scalars().all() return PaginatedResponse( items=[TechniquePageRead.model_validate(p) for p in pages], total=total, offset=offset, limit=limit, ) @router.get("/{slug}", response_model=TechniquePageDetail) async def get_technique( slug: str, db: AsyncSession = Depends(get_session), ) -> TechniquePageDetail: """Get full technique page detail with key moments, creator, and related links.""" stmt = ( select(TechniquePage) .where(TechniquePage.slug == slug) .options( selectinload(TechniquePage.key_moments).selectinload(KeyMoment.source_video), selectinload(TechniquePage.creator), selectinload(TechniquePage.outgoing_links).selectinload( RelatedTechniqueLink.target_page ), selectinload(TechniquePage.incoming_links).selectinload( RelatedTechniqueLink.source_page ), ) ) result = await db.execute(stmt) page = result.scalar_one_or_none() if page is None: raise HTTPException(status_code=404, detail=f"Technique '{slug}' not found") # Build key moments (ordered by start_time) key_moments = sorted(page.key_moments, key=lambda km: km.start_time) key_moment_items = [] for km in key_moments: item = KeyMomentSummary.model_validate(km) item.video_filename = km.source_video.filename if km.source_video else "" key_moment_items.append(item) # Build creator info creator_info = None if page.creator: creator_info = CreatorInfo( name=page.creator.name, slug=page.creator.slug, genres=page.creator.genres, ) # Build related links (outgoing + incoming) related_links: list[RelatedLinkItem] = [] for link in page.outgoing_links: if link.target_page: related_links.append( RelatedLinkItem( target_title=link.target_page.title, target_slug=link.target_page.slug, relationship=link.relationship.value if hasattr(link.relationship, 'value') else str(link.relationship), ) ) for link in page.incoming_links: if link.source_page: related_links.append( RelatedLinkItem( target_title=link.source_page.title, target_slug=link.source_page.slug, relationship=link.relationship.value if hasattr(link.relationship, 'value') else str(link.relationship), ) ) base = TechniquePageRead.model_validate(page) # Count versions for this page version_count_stmt = select(func.count()).where( TechniquePageVersion.technique_page_id == page.id ) version_count_result = await db.execute(version_count_stmt) version_count = version_count_result.scalar() or 0 return TechniquePageDetail( **base.model_dump(), key_moments=key_moment_items, creator_info=creator_info, related_links=related_links, version_count=version_count, ) @router.get("/{slug}/versions", response_model=TechniquePageVersionListResponse) async def list_technique_versions( slug: str, db: AsyncSession = Depends(get_session), ) -> TechniquePageVersionListResponse: """List all version snapshots for a technique page, newest first.""" # Resolve the technique page page_stmt = select(TechniquePage).where(TechniquePage.slug == slug) page_result = await db.execute(page_stmt) page = page_result.scalar_one_or_none() if page is None: raise HTTPException(status_code=404, detail=f"Technique '{slug}' not found") # Fetch versions ordered by version_number DESC versions_stmt = ( select(TechniquePageVersion) .where(TechniquePageVersion.technique_page_id == page.id) .order_by(TechniquePageVersion.version_number.desc()) ) versions_result = await db.execute(versions_stmt) versions = versions_result.scalars().all() items = [TechniquePageVersionSummary.model_validate(v) for v in versions] return TechniquePageVersionListResponse(items=items, total=len(items)) @router.get("/{slug}/versions/{version_number}", response_model=TechniquePageVersionDetail) async def get_technique_version( slug: str, version_number: int, db: AsyncSession = Depends(get_session), ) -> TechniquePageVersionDetail: """Get a specific version snapshot by version number.""" # Resolve the technique page page_stmt = select(TechniquePage).where(TechniquePage.slug == slug) page_result = await db.execute(page_stmt) page = page_result.scalar_one_or_none() if page is None: raise HTTPException(status_code=404, detail=f"Technique '{slug}' not found") # Fetch the specific version version_stmt = ( select(TechniquePageVersion) .where( TechniquePageVersion.technique_page_id == page.id, TechniquePageVersion.version_number == version_number, ) ) version_result = await db.execute(version_stmt) version = version_result.scalar_one_or_none() if version is None: raise HTTPException( status_code=404, detail=f"Version {version_number} not found for technique '{slug}'", ) return TechniquePageVersionDetail.model_validate(version)