diff --git a/alembic/versions/017_add_consent_tables.py b/alembic/versions/017_add_consent_tables.py new file mode 100644 index 0000000..3765984 --- /dev/null +++ b/alembic/versions/017_add_consent_tables.py @@ -0,0 +1,51 @@ +"""Add video_consents and consent_audit_log tables for per-video consent management. + +Revision ID: 017_add_consent_tables +Revises: 016_add_users_and_invite_codes +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + +revision = "017_add_consent_tables" +down_revision = "016_add_users_and_invite_codes" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Create video_consents table + op.create_table( + "video_consents", + sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.func.gen_random_uuid()), + sa.Column("source_video_id", UUID(as_uuid=True), sa.ForeignKey("source_videos.id", ondelete="CASCADE"), nullable=False), + sa.Column("creator_id", UUID(as_uuid=True), sa.ForeignKey("creators.id", ondelete="CASCADE"), nullable=False), + sa.Column("kb_inclusion", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("training_usage", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("public_display", sa.Boolean(), nullable=False, server_default="true"), + sa.Column("updated_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("source_video_id", name="uq_video_consent_video"), + ) + + # Create consent_audit_log table + op.create_table( + "consent_audit_log", + sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.func.gen_random_uuid()), + sa.Column("video_consent_id", UUID(as_uuid=True), sa.ForeignKey("video_consents.id", ondelete="CASCADE"), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("field_name", sa.String(50), nullable=False), + sa.Column("old_value", sa.Boolean(), nullable=True), + sa.Column("new_value", sa.Boolean(), nullable=False), + sa.Column("changed_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False), + sa.Column("ip_address", sa.String(45), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_consent_audit_log_video_consent_id", "consent_audit_log", ["video_consent_id"]) + + +def downgrade() -> None: + op.drop_index("ix_consent_audit_log_video_consent_id", table_name="consent_audit_log") + op.drop_table("consent_audit_log") + op.drop_table("video_consents") diff --git a/backend/models.py b/backend/models.py index 9da24a5..af50d12 100644 --- a/backend/models.py +++ b/backend/models.py @@ -566,3 +566,91 @@ class PipelineEvent(Base): run: Mapped[PipelineRun | None] = sa_relationship( back_populates="events", foreign_keys=[run_id] ) + + +# ── Consent Enums ──────────────────────────────────────────────────────────── + +class ConsentField(str, enum.Enum): + """Fields that can be individually consented to per video.""" + kb_inclusion = "kb_inclusion" + training_usage = "training_usage" + public_display = "public_display" + + +# ── Consent Models ─────────────────────────────────────────────────────────── + +class VideoConsent(Base): + """Current consent state for a source video. + + One row per video. Mutable — updated when a creator toggles consent. + The full change history lives in ConsentAuditLog. + """ + __tablename__ = "video_consents" + __table_args__ = ( + UniqueConstraint("source_video_id", name="uq_video_consent_video"), + ) + + id: Mapped[uuid.UUID] = _uuid_pk() + source_video_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("source_videos.id", ondelete="CASCADE"), nullable=False, + ) + creator_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("creators.id", ondelete="CASCADE"), nullable=False, + ) + kb_inclusion: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false", + ) + training_usage: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false", + ) + public_display: Mapped[bool] = mapped_column( + Boolean, default=True, server_default="true", + ) + updated_by: Mapped[uuid.UUID] = mapped_column( + ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, + ) + created_at: Mapped[datetime] = mapped_column( + default=_now, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + default=_now, server_default=func.now(), onupdate=_now + ) + + # relationships + source_video: Mapped[SourceVideo] = sa_relationship() + creator: Mapped[Creator] = sa_relationship() + audit_entries: Mapped[list[ConsentAuditLog]] = sa_relationship( + back_populates="video_consent", order_by="ConsentAuditLog.version" + ) + + +class ConsentAuditLog(Base): + """Append-only versioned record of per-field consent changes. + + Each row captures a single field change. Version is auto-assigned + in application code (max(version) + 1 per video_consent_id). + """ + __tablename__ = "consent_audit_log" + + id: Mapped[uuid.UUID] = _uuid_pk() + video_consent_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("video_consents.id", ondelete="CASCADE"), nullable=False, index=True, + ) + version: Mapped[int] = mapped_column(Integer, nullable=False) + field_name: Mapped[str] = mapped_column( + String(50), nullable=False, doc="ConsentField value: kb_inclusion, training_usage, public_display" + ) + old_value: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + new_value: Mapped[bool] = mapped_column(Boolean, nullable=False) + changed_by: Mapped[uuid.UUID] = mapped_column( + ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, + ) + ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True) + created_at: Mapped[datetime] = mapped_column( + default=_now, server_default=func.now() + ) + + # relationships + video_consent: Mapped[VideoConsent] = sa_relationship( + back_populates="audit_entries" + )