- "backend/schemas.py" - "backend/routers/techniques.py" - "backend/tests/test_public_api.py" GSD-Task: S04/T02
209 lines
7.3 KiB
Python
209 lines
7.3 KiB
Python
"""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)
|