refactor(models): one iso() for every to_dict timestamp; mixins replace hand-rolled created_at/updated_at (#2827, milestone 296 area 3)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 26s

Reading all 28 models against each other: 54 `x.isoformat() if x else None` /
`x.isoformat()` expressions in 23 to_dict methods, in two guarded/unguarded
wordings, become iso() from models/base.py — uniform, and a row read before
flush serialises as null instead of raising. Rulebook / RulebookTopic / Rule
carried byte-identical copies of TimestampMixin's two columns;
InvitationToken / PasswordResetToken / NoteUsageEvent carried CreatedAtMixin's —
all six now use the mixin. AppLog and RetrievalLog keep their explicit
created_at, commented: their composite index orders on `created_at.desc()`,
which needs the column object in the class body. Schema-neutral (same column
definitions) — no migration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 11:17:42 -04:00
co-authored by Claude Fable 5
parent 848ce1592e
commit b0eda32575
24 changed files with 101 additions and 119 deletions
+4 -4
View File
@@ -4,7 +4,7 @@ from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin from scribe.models.base import CreatedAtMixin, iso
class ApiKey(Base, CreatedAtMixin): class ApiKey(Base, CreatedAtMixin):
@@ -36,7 +36,7 @@ class ApiKey(Base, CreatedAtMixin):
"name": self.name, "name": self.name,
"key_prefix": self.key_prefix, "key_prefix": self.key_prefix,
"scope": self.scope, "scope": self.scope,
"last_used_at": self.last_used_at.isoformat() if self.last_used_at else None, "last_used_at": iso(self.last_used_at),
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"revoked_at": self.revoked_at.isoformat() if self.revoked_at else None, "revoked_at": iso(self.revoked_at),
} }
+5 -1
View File
@@ -4,6 +4,7 @@ from sqlalchemy import DateTime, Float, Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import iso
class AppLog(Base): class AppLog(Base):
@@ -20,6 +21,9 @@ class AppLog(Base):
duration_ms: Mapped[float | None] = mapped_column(Float, nullable=True) duration_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
ip_address: Mapped[str | None] = mapped_column(Text, nullable=True) ip_address: Mapped[str | None] = mapped_column(Text, nullable=True)
details: Mapped[str | None] = mapped_column(Text, nullable=True) details: Mapped[str | None] = mapped_column(Text, nullable=True)
# Declared here rather than via CreatedAtMixin on purpose: the composite
# index below orders on `created_at.desc()`, which needs the column object
# in this class body — a mixin's column is not in scope there.
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
) )
@@ -44,5 +48,5 @@ class AppLog(Base):
"duration_ms": self.duration_ms, "duration_ms": self.duration_ms,
"ip_address": self.ip_address, "ip_address": self.ip_address,
"details": self.details, "details": self.details,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": iso(self.created_at),
} }
+11 -1
View File
@@ -1,9 +1,19 @@
from datetime import datetime, timezone from datetime import date, datetime, timezone
from sqlalchemy import DateTime, Text from sqlalchemy import DateTime, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
def iso(value: datetime | date | None) -> str | None:
"""ISO-8601 for a payload, None for an unset column.
Every model's to_dict serialises timestamps through this one helper so a
row read before flush (created_at still None) and a nullable column both
come out as null instead of raising on `.isoformat()`.
"""
return value.isoformat() if value else None
class SoftDeleteMixin: class SoftDeleteMixin:
"""Recoverable-delete columns. NULL deleted_at = live row. deleted_batch_id """Recoverable-delete columns. NULL deleted_at = live row. deleted_batch_id
groups rows soft-deleted in one operation so a cascade restores as a unit.""" groups rows soft-deleted in one operation so a cascade restores as a unit."""
+7 -7
View File
@@ -13,7 +13,7 @@ from sqlalchemy import (
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import TimestampMixin from scribe.models.base import TimestampMixin, iso
# The classification vocabulary (note 2786). `unclassified` is the default and # The classification vocabulary (note 2786). `unclassified` is the default and
# THE todo state; every other status is a judgment, stamped with who made it. # THE todo state; every other status is a judgment, stamped with who made it.
@@ -153,18 +153,18 @@ class CodeShape(Base, TimestampMixin):
"snippet_id": self.snippet_id, "snippet_id": self.snippet_id,
"reason": self.reason, "reason": self.reason,
"classified_by": self.classified_by, "classified_by": self.classified_by,
"classified_at": self.classified_at.isoformat() if self.classified_at else None, "classified_at": iso(self.classified_at),
"first_seen_commit": self.first_seen_commit, "first_seen_commit": self.first_seen_commit,
"last_seen_commit": self.last_seen_commit, "last_seen_commit": self.last_seen_commit,
"vanished_at": self.vanished_at.isoformat() if self.vanished_at else None, "vanished_at": iso(self.vanished_at),
"signature": self.signature, "signature": self.signature,
"body_sha": self.body_sha, "body_sha": self.body_sha,
"proposal": self.proposal, "proposal": self.proposal,
"classified_sha": self.classified_sha, "classified_sha": self.classified_sha,
"recheck_at": self.recheck_at.isoformat() if self.recheck_at else None, "recheck_at": iso(self.recheck_at),
"diverges_from": self.diverges_from, "diverges_from": self.diverges_from,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
@@ -221,5 +221,5 @@ class CodeShapeEvent(Base):
"classified_by": self.classified_by, "classified_by": self.classified_by,
"reason": self.reason, "reason": self.reason,
"commit": self.commit, "commit": self.commit,
"at": self.at.isoformat(), "at": iso(self.at),
} }
+5 -5
View File
@@ -20,7 +20,7 @@ from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import SoftDeleteMixin, TimestampMixin from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
class DesignSystem(Base, TimestampMixin, SoftDeleteMixin): class DesignSystem(Base, TimestampMixin, SoftDeleteMixin):
@@ -56,8 +56,8 @@ class DesignSystem(Base, TimestampMixin, SoftDeleteMixin):
"description": self.description or "", "description": self.description or "",
"guidance": self.guidance or "", "guidance": self.guidance or "",
"parent_id": self.parent_id, "parent_id": self.parent_id,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": iso(self.updated_at),
} }
@@ -153,6 +153,6 @@ class DesignToken(Base, TimestampMixin, SoftDeleteMixin):
"rationale": self.rationale, "rationale": self.rationale,
"supersedes": self.supersedes or [], "supersedes": self.supersedes or [],
"order_index": self.order_index, "order_index": self.order_index,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": iso(self.updated_at),
} }
+3 -3
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import TimestampMixin from scribe.models.base import TimestampMixin, iso
class ForgeConnection(Base, TimestampMixin): class ForgeConnection(Base, TimestampMixin):
@@ -40,6 +40,6 @@ class ForgeConnection(Base, TimestampMixin):
"kind": self.kind, "kind": self.kind,
"base_url": self.base_url, "base_url": self.base_url,
"host": self.host, "host": self.host,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
+4 -4
View File
@@ -4,7 +4,7 @@ from sqlalchemy import ForeignKey, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin, TimestampMixin from scribe.models.base import CreatedAtMixin, TimestampMixin, iso
class Group(Base, TimestampMixin): class Group(Base, TimestampMixin):
@@ -27,8 +27,8 @@ class Group(Base, TimestampMixin):
"name": self.name, "name": self.name,
"description": self.description, "description": self.description,
"created_by": self.created_by, "created_by": self.created_by,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
@@ -53,5 +53,5 @@ class GroupMembership(Base, CreatedAtMixin):
"group_id": self.group_id, "group_id": self.group_id,
"user_id": self.user_id, "user_id": self.user_id,
"role": self.role, "role": self.role,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
} }
+2 -4
View File
@@ -4,9 +4,10 @@ from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin
class InvitationToken(Base): class InvitationToken(Base, CreatedAtMixin):
__tablename__ = "invitation_tokens" __tablename__ = "invitation_tokens"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
@@ -15,9 +16,6 @@ class InvitationToken(Base):
invited_by: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False) invited_by: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) used: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
__table_args__ = ( __table_args__ = (
Index("ix_invitation_tokens_token_hash", "token_hash"), Index("ix_invitation_tokens_token_hash", "token_hash"),
+3 -3
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import TimestampMixin, SoftDeleteMixin from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
class Milestone(Base, TimestampMixin, SoftDeleteMixin): class Milestone(Base, TimestampMixin, SoftDeleteMixin):
@@ -30,6 +30,6 @@ class Milestone(Base, TimestampMixin, SoftDeleteMixin):
"body": self.body, "body": self.body,
"status": self.status, "status": self.status,
"order_index": self.order_index, "order_index": self.order_index,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
+7 -11
View File
@@ -6,7 +6,7 @@ from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import TimestampMixin, SoftDeleteMixin from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
class TaskStatus(str, enum.Enum): class TaskStatus(str, enum.Enum):
@@ -105,18 +105,14 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
"milestone_id": self.milestone_id, "milestone_id": self.milestone_id,
"status": self.status, "status": self.status,
"priority": self.priority, "priority": self.priority,
"due_date": self.due_date.isoformat() if self.due_date else None, "due_date": iso(self.due_date),
"started_at": self.started_at.isoformat() if self.started_at else None, "started_at": iso(self.started_at),
"completed_at": self.completed_at.isoformat() if self.completed_at else None, "completed_at": iso(self.completed_at),
"recurrence_rule": self.recurrence_rule, "recurrence_rule": self.recurrence_rule,
"recurrence_next_spawn_at": ( "recurrence_next_spawn_at": iso(self.recurrence_next_spawn_at),
self.recurrence_next_spawn_at.isoformat()
if self.recurrence_next_spawn_at
else None
),
"is_task": self.is_task, "is_task": self.is_task,
"note_type": self.note_type or "note", "note_type": self.note_type or "note",
"task_kind": self.task_kind, "task_kind": self.task_kind,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
+3 -3
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import TimestampMixin from scribe.models.base import TimestampMixin, iso
class NoteDraft(Base, TimestampMixin): class NoteDraft(Base, TimestampMixin):
@@ -25,6 +25,6 @@ class NoteDraft(Base, TimestampMixin):
"original_body": self.original_body, "original_body": self.original_body,
"instruction": self.instruction, "instruction": self.instruction,
"scope": self.scope, "scope": self.scope,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
+2 -2
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin from scribe.models.base import CreatedAtMixin, iso
class NoteSupersession(Base, CreatedAtMixin): class NoteSupersession(Base, CreatedAtMixin):
@@ -66,5 +66,5 @@ class NoteSupersession(Base, CreatedAtMixin):
"id": self.id, "id": self.id,
"superseder_id": self.superseder_id, "superseder_id": self.superseder_id,
"superseded_id": self.superseded_id, "superseded_id": self.superseded_id,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": iso(self.created_at),
} }
+4 -8
View File
@@ -1,15 +1,14 @@
from datetime import datetime, timezone from sqlalchemy import Index, Integer, Text
from sqlalchemy import DateTime, Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin, iso
SURFACED = "surfaced" SURFACED = "surfaced"
PULLED = "pulled" PULLED = "pulled"
class NoteUsageEvent(Base): class NoteUsageEvent(Base, CreatedAtMixin):
"""One row per time a note was SURFACED to the agent, or PULLED in full. """One row per time a note was SURFACED to the agent, or PULLED in full.
Answers the question RetrievalLog cannot: not "what did the ranker return Answers the question RetrievalLog cannot: not "what did the ranker return
@@ -44,9 +43,6 @@ class NoteUsageEvent(Base):
__tablename__ = "note_usage_events" __tablename__ = "note_usage_events"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
user_id: Mapped[int | None] = mapped_column(Integer, nullable=True) user_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
note_id: Mapped[int] = mapped_column(Integer, nullable=False) note_id: Mapped[int] = mapped_column(Integer, nullable=False)
# 'surfaced' | 'pulled' # 'surfaced' | 'pulled'
@@ -81,7 +77,7 @@ class NoteUsageEvent(Base):
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
"id": self.id, "id": self.id,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": iso(self.created_at),
"user_id": self.user_id, "user_id": self.user_id,
"note_id": self.note_id, "note_id": self.note_id,
"event": self.event, "event": self.event,
+2 -2
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ARRAY, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin from scribe.models.base import CreatedAtMixin, iso
class NoteVersion(Base, CreatedAtMixin): class NoteVersion(Base, CreatedAtMixin):
@@ -26,7 +26,7 @@ class NoteVersion(Base, CreatedAtMixin):
"tags": self.tags or [], "tags": self.tags or [],
"pin_kind": self.pin_kind, "pin_kind": self.pin_kind,
"pin_label": self.pin_label, "pin_label": self.pin_label,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
} }
if include_body: if include_body:
d["body"] = self.body d["body"] = self.body
+3 -3
View File
@@ -5,7 +5,7 @@ from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin from scribe.models.base import CreatedAtMixin, iso
class Notification(Base, CreatedAtMixin): class Notification(Base, CreatedAtMixin):
@@ -26,6 +26,6 @@ class Notification(Base, CreatedAtMixin):
"user_id": self.user_id, "user_id": self.user_id,
"type": self.type, "type": self.type,
"payload": self.payload, "payload": self.payload,
"read_at": self.read_at.isoformat() if self.read_at else None, "read_at": iso(self.read_at),
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
} }
+2 -4
View File
@@ -4,9 +4,10 @@ from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin
class PasswordResetToken(Base): class PasswordResetToken(Base, CreatedAtMixin):
__tablename__ = "password_reset_tokens" __tablename__ = "password_reset_tokens"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
@@ -14,9 +15,6 @@ class PasswordResetToken(Base):
token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True) token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) used: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
__table_args__ = ( __table_args__ = (
Index("ix_password_reset_tokens_token_hash", "token_hash"), Index("ix_password_reset_tokens_token_hash", "token_hash"),
+3 -3
View File
@@ -2,7 +2,7 @@ import enum
from sqlalchemy import BigInteger, ForeignKey, Integer, Text from sqlalchemy import BigInteger, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import TimestampMixin, SoftDeleteMixin from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
class ProjectStatus(str, enum.Enum): class ProjectStatus(str, enum.Enum):
@@ -48,6 +48,6 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
"color": self.color, "color": self.color,
"design_system_id": self.design_system_id, "design_system_id": self.design_system_id,
"forge_connection_id": self.forge_connection_id, "forge_connection_id": self.forge_connection_id,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
+3 -3
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import TimestampMixin from scribe.models.base import TimestampMixin, iso
class RepoBinding(Base, TimestampMixin): class RepoBinding(Base, TimestampMixin):
@@ -35,6 +35,6 @@ class RepoBinding(Base, TimestampMixin):
"user_id": self.user_id, "user_id": self.user_id,
"project_id": self.project_id, "project_id": self.project_id,
"repo_key": self.repo_key, "repo_key": self.repo_key,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
+5 -1
View File
@@ -5,6 +5,7 @@ from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import iso
class RetrievalLog(Base): class RetrievalLog(Base):
@@ -23,6 +24,9 @@ class RetrievalLog(Base):
__tablename__ = "retrieval_logs" __tablename__ = "retrieval_logs"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
# Declared here rather than via CreatedAtMixin on purpose: the composite
# index below orders on `created_at.desc()`, which needs the column object
# in this class body — a mixin's column is not in scope there.
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
) )
@@ -54,7 +58,7 @@ class RetrievalLog(Base):
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
"id": self.id, "id": self.id,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": iso(self.created_at),
"user_id": self.user_id, "user_id": self.user_id,
"source": self.source, "source": self.source,
"query": self.query, "query": self.query,
+10 -34
View File
@@ -4,10 +4,10 @@ from sqlalchemy import BigInteger, Boolean, Column, DateTime, ForeignKey, Index,
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import SoftDeleteMixin from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
class Rulebook(Base, SoftDeleteMixin): class Rulebook(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "rulebooks" __tablename__ = "rulebooks"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True) id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
@@ -19,14 +19,6 @@ class Rulebook(Base, SoftDeleteMixin):
always_on: Mapped[bool] = mapped_column( always_on: Mapped[bool] = mapped_column(
Boolean, default=False, nullable=False, server_default="false" 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: def to_dict(self) -> dict:
return { return {
@@ -35,12 +27,12 @@ class Rulebook(Base, SoftDeleteMixin):
"title": self.title, "title": self.title,
"description": self.description or "", "description": self.description or "",
"always_on": self.always_on, "always_on": self.always_on,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": iso(self.updated_at),
} }
class RulebookTopic(Base, SoftDeleteMixin): class RulebookTopic(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "rulebook_topics" __tablename__ = "rulebook_topics"
# Partial unique: a title is unique among LIVE topics in a rulebook, so a # Partial unique: a title is unique among LIVE topics in a rulebook, so a
# trashed topic doesn't block recreating/restoring the same title. # trashed topic doesn't block recreating/restoring the same title.
@@ -58,14 +50,6 @@ class RulebookTopic(Base, SoftDeleteMixin):
title: Mapped[str] = mapped_column(Text) title: Mapped[str] = mapped_column(Text)
description: Mapped[str | None] = mapped_column(Text, nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True)
order_index: Mapped[int] = mapped_column(Integer, default=0) 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: def to_dict(self) -> dict:
return { return {
@@ -74,12 +58,12 @@ class RulebookTopic(Base, SoftDeleteMixin):
"title": self.title, "title": self.title,
"description": self.description or "", "description": self.description or "",
"order_index": self.order_index, "order_index": self.order_index,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": iso(self.updated_at),
} }
class Rule(Base, SoftDeleteMixin): class Rule(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "rules" __tablename__ = "rules"
# Partial unique: title unique among LIVE rules in a topic (soft-deleted # Partial unique: title unique among LIVE rules in a topic (soft-deleted
# rules don't block recreating/restoring the same title). # rules don't block recreating/restoring the same title).
@@ -109,14 +93,6 @@ class Rule(Base, SoftDeleteMixin):
why: Mapped[str | None] = mapped_column(Text, nullable=True) why: Mapped[str | None] = mapped_column(Text, nullable=True)
how_to_apply: 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) 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: def to_dict(self) -> dict:
return { return {
@@ -128,8 +104,8 @@ class Rule(Base, SoftDeleteMixin):
"why": self.why or "", "why": self.why or "",
"how_to_apply": self.how_to_apply or "", "how_to_apply": self.how_to_apply or "",
"order_index": self.order_index, "order_index": self.order_index,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": iso(self.updated_at),
} }
+5 -5
View File
@@ -2,7 +2,7 @@ from sqlalchemy import CheckConstraint, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import TimestampMixin from scribe.models.base import TimestampMixin, iso
class ProjectShare(Base, TimestampMixin): class ProjectShare(Base, TimestampMixin):
@@ -37,8 +37,8 @@ class ProjectShare(Base, TimestampMixin):
"shared_with_group_id": self.shared_with_group_id, "shared_with_group_id": self.shared_with_group_id,
"permission": self.permission, "permission": self.permission,
"invited_by": self.invited_by, "invited_by": self.invited_by,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
@@ -74,6 +74,6 @@ class NoteShare(Base, TimestampMixin):
"shared_with_group_id": self.shared_with_group_id, "shared_with_group_id": self.shared_with_group_id,
"permission": self.permission, "permission": self.permission,
"invited_by": self.invited_by, "invited_by": self.invited_by,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
+3 -3
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Index, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin, TimestampMixin, SoftDeleteMixin from scribe.models.base import CreatedAtMixin, SoftDeleteMixin, TimestampMixin, iso
class System(Base, TimestampMixin, SoftDeleteMixin): class System(Base, TimestampMixin, SoftDeleteMixin):
@@ -44,8 +44,8 @@ class System(Base, TimestampMixin, SoftDeleteMixin):
"color": self.color, "color": self.color,
"status": self.status, "status": self.status,
"order_index": self.order_index, "order_index": self.order_index,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
+3 -3
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import TimestampMixin from scribe.models.base import TimestampMixin, iso
class TaskLog(Base, TimestampMixin): class TaskLog(Base, TimestampMixin):
@@ -21,6 +21,6 @@ class TaskLog(Base, TimestampMixin):
"user_id": self.user_id, "user_id": self.user_id,
"content": self.content, "content": self.content,
"duration_minutes": self.duration_minutes, "duration_minutes": self.duration_minutes,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"updated_at": self.updated_at.isoformat(), "updated_at": iso(self.updated_at),
} }
+2 -2
View File
@@ -2,7 +2,7 @@ from sqlalchemy import Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
from scribe.models.base import CreatedAtMixin from scribe.models.base import CreatedAtMixin, iso
class User(Base, CreatedAtMixin): class User(Base, CreatedAtMixin):
@@ -26,6 +26,6 @@ class User(Base, CreatedAtMixin):
"username": self.username, "username": self.username,
"email": self.email, "email": self.email,
"role": self.role, "role": self.role,
"created_at": self.created_at.isoformat(), "created_at": iso(self.created_at),
"has_password": self.password_hash is not None, "has_password": self.password_hash is not None,
} }