from datetime import datetime, timezone from sqlalchemy import BigInteger, Boolean, Column, DateTime, ForeignKey, Index, Integer, Table, Text, text from sqlalchemy.orm import Mapped, mapped_column from scribe.models import Base from scribe.models.base import SoftDeleteMixin class Rulebook(Base, SoftDeleteMixin): __tablename__ = "rulebooks" id: Mapped[int] = mapped_column(BigInteger, primary_key=True) owner_user_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("users.id", ondelete="CASCADE") ) title: Mapped[str] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text, nullable=True) always_on: Mapped[bool] = mapped_column( Boolean, default=False, nullable=False, server_default="false" ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), ) def to_dict(self) -> dict: return { "id": self.id, "owner_user_id": self.owner_user_id, "title": self.title, "description": self.description or "", "always_on": self.always_on, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, } class RulebookTopic(Base, SoftDeleteMixin): __tablename__ = "rulebook_topics" # Partial unique: a title is unique among LIVE topics in a rulebook, so a # trashed topic doesn't block recreating/restoring the same title. __table_args__ = ( Index( "uq_topic_per_rulebook", "rulebook_id", "title", unique=True, postgresql_where=text("deleted_at IS NULL"), ), ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True) rulebook_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE") ) title: Mapped[str] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text, nullable=True) order_index: Mapped[int] = mapped_column(Integer, default=0) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), ) def to_dict(self) -> dict: return { "id": self.id, "rulebook_id": self.rulebook_id, "title": self.title, "description": self.description or "", "order_index": self.order_index, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, } class Rule(Base, SoftDeleteMixin): __tablename__ = "rules" # Partial unique: title unique among LIVE rules in a topic (soft-deleted # rules don't block recreating/restoring the same title). __table_args__ = ( Index( "uq_rule_per_topic", "topic_id", "title", unique=True, postgresql_where=text("deleted_at IS NULL"), ), ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True) # Exactly one of topic_id / project_id is set — enforced by CHECK # constraint ck_rule_topic_xor_project (migration 0059). topic_id: Mapped[int | None] = mapped_column( BigInteger, ForeignKey("rulebook_topics.id", ondelete="CASCADE"), nullable=True, ) project_id: Mapped[int | None] = mapped_column( BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), nullable=True, index=True, ) title: Mapped[str] = mapped_column(Text) statement: Mapped[str] = mapped_column(Text) why: Mapped[str | None] = mapped_column(Text, nullable=True) how_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True) order_index: Mapped[int] = mapped_column(Integer, default=0) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), ) def to_dict(self) -> dict: return { "id": self.id, "topic_id": self.topic_id, "project_id": self.project_id, "title": self.title, "statement": self.statement, "why": self.why or "", "how_to_apply": self.how_to_apply or "", "order_index": self.order_index, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, } # Pure many-to-many — no model class, just the join table. project_rulebook_subscriptions = Table( "project_rulebook_subscriptions", Base.metadata, Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True), Column("rulebook_id", BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE"), primary_key=True), Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)), ) # Suppressions — let a project mute individual rules or whole topics from # rulebooks it subscribes to, without unsubscribing the rulebook itself. # FKs CASCADE so the row vanishes when its parent is removed. project_rule_suppressions = Table( "project_rule_suppressions", Base.metadata, Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True), Column("rule_id", BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True), Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)), ) project_topic_suppressions = Table( "project_topic_suppressions", Base.metadata, Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True), Column("topic_id", BigInteger, ForeignKey("rulebook_topics.id", ondelete="CASCADE"), primary_key=True), Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)), )