From 45c2197cdf82710c29f9ec990ce9c6096d3d70db Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 27 May 2026 21:16:49 -0400 Subject: [PATCH] feat(rulebook): SQLAlchemy models for rulebooks, topics, rules --- src/fabledassistant/models/__init__.py | 3 + src/fabledassistant/models/rulebook.py | 117 +++++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 src/fabledassistant/models/rulebook.py diff --git a/src/fabledassistant/models/__init__.py b/src/fabledassistant/models/__init__.py index 9ea3232..4288178 100644 --- a/src/fabledassistant/models/__init__.py +++ b/src/fabledassistant/models/__init__.py @@ -37,3 +37,6 @@ from fabledassistant.models.share import NoteShare, ProjectShare # noqa: E402, from fabledassistant.models.notification import Notification # noqa: E402, F401 from fabledassistant.models.api_key import ApiKey # noqa: E402, F401 from fabledassistant.models.user_profile import UserProfile # noqa: E402, F401 +from fabledassistant.models.rulebook import ( # noqa: E402, F401 + Rulebook, RulebookTopic, Rule, project_rulebook_subscriptions, +) diff --git a/src/fabledassistant/models/rulebook.py b/src/fabledassistant/models/rulebook.py new file mode 100644 index 0000000..3de66ff --- /dev/null +++ b/src/fabledassistant/models/rulebook.py @@ -0,0 +1,117 @@ +from datetime import datetime, timezone + +from sqlalchemy import BigInteger, Column, DateTime, ForeignKey, Integer, Table, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from fabledassistant.models import Base + + +class Rulebook(Base): + __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) + 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 "", + "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): + __tablename__ = "rulebook_topics" + __table_args__ = ( + UniqueConstraint("rulebook_id", "title", name="uq_topic_per_rulebook"), + ) + + 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): + __tablename__ = "rules" + __table_args__ = ( + UniqueConstraint("topic_id", "title", name="uq_rule_per_topic"), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + topic_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("rulebook_topics.id", ondelete="CASCADE") + ) + 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, + "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)), +)