From ae98e4e30ef1b8c36a8ee9dd51f93c9ce072f303 Mon Sep 17 00:00:00 2001 From: jlightner Date: Fri, 3 Apr 2026 01:16:31 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Added=20body=5Fsections=5Fformat=20colu?= =?UTF-8?q?mn,=20technique=5Fpage=5Fvideos=20associat=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "alembic/versions/012_multi_source_format.py" - "backend/models.py" - "backend/schemas.py" GSD-Task: S03/T01 --- alembic/versions/012_multi_source_format.py | 55 +++++++++++++++++++++ backend/models.py | 31 ++++++++++++ backend/schemas.py | 14 +++++- conftest.py | 7 +++ 4 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/012_multi_source_format.py create mode 100644 conftest.py diff --git a/alembic/versions/012_multi_source_format.py b/alembic/versions/012_multi_source_format.py new file mode 100644 index 0000000..8e9bd97 --- /dev/null +++ b/alembic/versions/012_multi_source_format.py @@ -0,0 +1,55 @@ +"""Add body_sections_format column and technique_page_videos association table. + +Supports multi-source technique pages: tracks which source videos contributed +to a technique page, and marks the body_sections format version for future +structured section layouts. + +Revision ID: 012_multi_source_fmt +Revises: 011_cls_cache_rerun +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + +revision = "012_multi_source_fmt" +down_revision = "011_cls_cache_rerun" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add body_sections_format to technique_pages with default for existing rows + op.add_column( + "technique_pages", + sa.Column( + "body_sections_format", + sa.String(20), + nullable=False, + server_default="v1", + ), + ) + + # Create technique_page_videos association table + op.create_table( + "technique_page_videos", + sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.func.gen_random_uuid()), + sa.Column( + "technique_page_id", + UUID(as_uuid=True), + sa.ForeignKey("technique_pages.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "source_video_id", + UUID(as_uuid=True), + sa.ForeignKey("source_videos.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("added_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("technique_page_id", "source_video_id", name="uq_page_video"), + ) + + +def downgrade() -> None: + op.drop_table("technique_page_videos") + op.drop_column("technique_pages", "body_sections_format") diff --git a/backend/models.py b/backend/models.py index 5a0e3ed..a2b0f9b 100644 --- a/backend/models.py +++ b/backend/models.py @@ -216,6 +216,9 @@ class TechniquePage(Base): topic_tags: Mapped[list[str] | None] = mapped_column(ARRAY(String), nullable=True) summary: Mapped[str | None] = mapped_column(Text, nullable=True) body_sections: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + body_sections_format: Mapped[str] = mapped_column( + String(20), nullable=False, default="v1", server_default="v1" + ) signal_chains: Mapped[list | None] = mapped_column(JSONB, nullable=True) plugins: Mapped[list[str] | None] = mapped_column(ARRAY(String), nullable=True) source_quality: Mapped[SourceQuality | None] = mapped_column( @@ -244,6 +247,9 @@ class TechniquePage(Base): incoming_links: Mapped[list[RelatedTechniqueLink]] = sa_relationship( foreign_keys="RelatedTechniqueLink.target_page_id", back_populates="target_page" ) + source_video_links: Mapped[list[TechniquePageVideo]] = sa_relationship( + back_populates="technique_page" + ) class RelatedTechniqueLink(Base): @@ -303,6 +309,31 @@ class Tag(Base): aliases: Mapped[list[str] | None] = mapped_column(ARRAY(String), nullable=True) +class TechniquePageVideo(Base): + """Association linking a technique page to its contributing source videos.""" + __tablename__ = "technique_page_videos" + __table_args__ = ( + UniqueConstraint("technique_page_id", "source_video_id", name="uq_page_video"), + ) + + id: Mapped[uuid.UUID] = _uuid_pk() + technique_page_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("technique_pages.id", ondelete="CASCADE"), nullable=False + ) + source_video_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("source_videos.id", ondelete="CASCADE"), nullable=False + ) + added_at: Mapped[datetime] = mapped_column( + default=_now, server_default=func.now() + ) + + # relationships + technique_page: Mapped[TechniquePage] = sa_relationship( + back_populates="source_video_links" + ) + source_video: Mapped[SourceVideo] = sa_relationship() + + # ── Content Report Enums ───────────────────────────────────────────────────── class ReportType(str, enum.Enum): diff --git a/backend/schemas.py b/backend/schemas.py index a14c84f..d38d657 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -122,7 +122,8 @@ class TechniquePageBase(BaseModel): topic_category: str topic_tags: list[str] | None = None summary: str | None = None - body_sections: dict | None = None + body_sections: list | dict | None = None + body_sections_format: str = "v1" signal_chains: list | None = None plugins: list[str] | None = None @@ -275,12 +276,23 @@ class CreatorInfo(BaseModel): genres: list[str] | None = None +class SourceVideoSummary(BaseModel): + """Lightweight source video info for technique page detail.""" + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + filename: str + content_type: str + added_at: datetime | None = None + + class TechniquePageDetail(TechniquePageRead): """Technique page with nested key moments, creator, and related links.""" key_moments: list[KeyMomentSummary] = Field(default_factory=list) creator_info: CreatorInfo | None = None related_links: list[RelatedLinkItem] = Field(default_factory=list) version_count: int = 0 + source_videos: list[SourceVideoSummary] = Field(default_factory=list) # ── Technique Page Versions ────────────────────────────────────────────────── diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..854d8bc --- /dev/null +++ b/conftest.py @@ -0,0 +1,7 @@ +"""Root conftest: ensure backend/ is on sys.path for symlinked test discovery.""" +import os +import sys + +_backend = os.path.join(os.path.dirname(__file__), "backend") +if _backend not in sys.path: + sys.path.insert(0, _backend)