From b0eda3257515c2ddb5ab50c5cf4bba5545bab1ef Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 21 Aug 2026 11:17:42 -0400 Subject: [PATCH] refactor(models): one iso() for every to_dict timestamp; mixins replace hand-rolled created_at/updated_at (#2827, milestone 296 area 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/scribe/models/api_key.py | 8 ++--- src/scribe/models/app_log.py | 6 +++- src/scribe/models/base.py | 12 ++++++- src/scribe/models/code_shape.py | 14 ++++---- src/scribe/models/design_system.py | 10 +++--- src/scribe/models/forge_connection.py | 6 ++-- src/scribe/models/group.py | 8 ++--- src/scribe/models/invitation.py | 6 ++-- src/scribe/models/milestone.py | 6 ++-- src/scribe/models/note.py | 18 ++++------- src/scribe/models/note_draft.py | 6 ++-- src/scribe/models/note_supersession.py | 4 +-- src/scribe/models/note_usage.py | 12 +++---- src/scribe/models/note_version.py | 4 +-- src/scribe/models/notification.py | 6 ++-- src/scribe/models/password_reset.py | 6 ++-- src/scribe/models/project.py | 6 ++-- src/scribe/models/repo_binding.py | 6 ++-- src/scribe/models/retrieval_log.py | 6 +++- src/scribe/models/rulebook.py | 44 ++++++-------------------- src/scribe/models/share.py | 10 +++--- src/scribe/models/system.py | 6 ++-- src/scribe/models/task_log.py | 6 ++-- src/scribe/models/user.py | 4 +-- 24 files changed, 101 insertions(+), 119 deletions(-) diff --git a/src/scribe/models/api_key.py b/src/scribe/models/api_key.py index 4093faf..181764a 100644 --- a/src/scribe/models/api_key.py +++ b/src/scribe/models/api_key.py @@ -4,7 +4,7 @@ from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text from sqlalchemy.orm import Mapped, mapped_column from scribe.models import Base -from scribe.models.base import CreatedAtMixin +from scribe.models.base import CreatedAtMixin, iso class ApiKey(Base, CreatedAtMixin): @@ -36,7 +36,7 @@ class ApiKey(Base, CreatedAtMixin): "name": self.name, "key_prefix": self.key_prefix, "scope": self.scope, - "last_used_at": self.last_used_at.isoformat() if self.last_used_at else None, - "created_at": self.created_at.isoformat(), - "revoked_at": self.revoked_at.isoformat() if self.revoked_at else None, + "last_used_at": iso(self.last_used_at), + "created_at": iso(self.created_at), + "revoked_at": iso(self.revoked_at), } diff --git a/src/scribe/models/app_log.py b/src/scribe/models/app_log.py index 32c3910..a8b25b4 100644 --- a/src/scribe/models/app_log.py +++ b/src/scribe/models/app_log.py @@ -4,6 +4,7 @@ from sqlalchemy import DateTime, Float, Index, Integer, Text from sqlalchemy.orm import Mapped, mapped_column from scribe.models import Base +from scribe.models.base import iso class AppLog(Base): @@ -20,6 +21,9 @@ class AppLog(Base): duration_ms: Mapped[float | None] = mapped_column(Float, nullable=True) ip_address: 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( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) ) @@ -44,5 +48,5 @@ class AppLog(Base): "duration_ms": self.duration_ms, "ip_address": self.ip_address, "details": self.details, - "created_at": self.created_at.isoformat() if self.created_at else None, + "created_at": iso(self.created_at), } diff --git a/src/scribe/models/base.py b/src/scribe/models/base.py index 8715097..42980e0 100644 --- a/src/scribe/models/base.py +++ b/src/scribe/models/base.py @@ -1,9 +1,19 @@ -from datetime import datetime, timezone +from datetime import date, datetime, timezone from sqlalchemy import DateTime, Text 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: """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.""" diff --git a/src/scribe/models/code_shape.py b/src/scribe/models/code_shape.py index 7cb1967..5dc0ab5 100644 --- a/src/scribe/models/code_shape.py +++ b/src/scribe/models/code_shape.py @@ -13,7 +13,7 @@ from sqlalchemy import ( from sqlalchemy.orm import Mapped, mapped_column 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 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, "reason": self.reason, "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, "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, "body_sha": self.body_sha, "proposal": self.proposal, "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, - "created_at": self.created_at.isoformat(), - "updated_at": self.updated_at.isoformat(), + "created_at": iso(self.created_at), + "updated_at": iso(self.updated_at), } @@ -221,5 +221,5 @@ class CodeShapeEvent(Base): "classified_by": self.classified_by, "reason": self.reason, "commit": self.commit, - "at": self.at.isoformat(), + "at": iso(self.at), } diff --git a/src/scribe/models/design_system.py b/src/scribe/models/design_system.py index d2d8570..0b5c29f 100644 --- a/src/scribe/models/design_system.py +++ b/src/scribe/models/design_system.py @@ -20,7 +20,7 @@ from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column 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): @@ -56,8 +56,8 @@ class DesignSystem(Base, TimestampMixin, SoftDeleteMixin): "description": self.description or "", "guidance": self.guidance or "", "parent_id": self.parent_id, - "created_at": self.created_at.isoformat() if self.created_at else None, - "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "created_at": iso(self.created_at), + "updated_at": iso(self.updated_at), } @@ -153,6 +153,6 @@ class DesignToken(Base, TimestampMixin, SoftDeleteMixin): "rationale": self.rationale, "supersedes": self.supersedes 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, + "created_at": iso(self.created_at), + "updated_at": iso(self.updated_at), } diff --git a/src/scribe/models/forge_connection.py b/src/scribe/models/forge_connection.py index 977f26b..9ecbdee 100644 --- a/src/scribe/models/forge_connection.py +++ b/src/scribe/models/forge_connection.py @@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Integer, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column from scribe.models import Base -from scribe.models.base import TimestampMixin +from scribe.models.base import TimestampMixin, iso class ForgeConnection(Base, TimestampMixin): @@ -40,6 +40,6 @@ class ForgeConnection(Base, TimestampMixin): "kind": self.kind, "base_url": self.base_url, "host": self.host, - "created_at": self.created_at.isoformat(), - "updated_at": self.updated_at.isoformat(), + "created_at": iso(self.created_at), + "updated_at": iso(self.updated_at), } diff --git a/src/scribe/models/group.py b/src/scribe/models/group.py index 1a58834..c10a53d 100644 --- a/src/scribe/models/group.py +++ b/src/scribe/models/group.py @@ -4,7 +4,7 @@ from sqlalchemy import ForeignKey, Integer, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from scribe.models import Base -from scribe.models.base import CreatedAtMixin, TimestampMixin +from scribe.models.base import CreatedAtMixin, TimestampMixin, iso class Group(Base, TimestampMixin): @@ -27,8 +27,8 @@ class Group(Base, TimestampMixin): "name": self.name, "description": self.description, "created_by": self.created_by, - "created_at": self.created_at.isoformat(), - "updated_at": self.updated_at.isoformat(), + "created_at": iso(self.created_at), + "updated_at": iso(self.updated_at), } @@ -53,5 +53,5 @@ class GroupMembership(Base, CreatedAtMixin): "group_id": self.group_id, "user_id": self.user_id, "role": self.role, - "created_at": self.created_at.isoformat(), + "created_at": iso(self.created_at), } diff --git a/src/scribe/models/invitation.py b/src/scribe/models/invitation.py index 432267b..55f3c2a 100644 --- a/src/scribe/models/invitation.py +++ b/src/scribe/models/invitation.py @@ -4,9 +4,10 @@ from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Text from sqlalchemy.orm import Mapped, mapped_column from scribe.models import Base +from scribe.models.base import CreatedAtMixin -class InvitationToken(Base): +class InvitationToken(Base, CreatedAtMixin): __tablename__ = "invitation_tokens" 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) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), 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__ = ( Index("ix_invitation_tokens_token_hash", "token_hash"), diff --git a/src/scribe/models/milestone.py b/src/scribe/models/milestone.py index a449204..72e7f39 100644 --- a/src/scribe/models/milestone.py +++ b/src/scribe/models/milestone.py @@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Integer, Text from sqlalchemy.orm import Mapped, mapped_column 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): @@ -30,6 +30,6 @@ class Milestone(Base, TimestampMixin, SoftDeleteMixin): "body": self.body, "status": self.status, "order_index": self.order_index, - "created_at": self.created_at.isoformat(), - "updated_at": self.updated_at.isoformat(), + "created_at": iso(self.created_at), + "updated_at": iso(self.updated_at), } diff --git a/src/scribe/models/note.py b/src/scribe/models/note.py index 2ba05f3..f09b16e 100644 --- a/src/scribe/models/note.py +++ b/src/scribe/models/note.py @@ -6,7 +6,7 @@ from sqlalchemy.dialects.postgresql import ARRAY, JSONB from sqlalchemy.orm import Mapped, mapped_column 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): @@ -105,18 +105,14 @@ class Note(Base, TimestampMixin, SoftDeleteMixin): "milestone_id": self.milestone_id, "status": self.status, "priority": self.priority, - "due_date": self.due_date.isoformat() if self.due_date else None, - "started_at": self.started_at.isoformat() if self.started_at else None, - "completed_at": self.completed_at.isoformat() if self.completed_at else None, + "due_date": iso(self.due_date), + "started_at": iso(self.started_at), + "completed_at": iso(self.completed_at), "recurrence_rule": self.recurrence_rule, - "recurrence_next_spawn_at": ( - self.recurrence_next_spawn_at.isoformat() - if self.recurrence_next_spawn_at - else None - ), + "recurrence_next_spawn_at": iso(self.recurrence_next_spawn_at), "is_task": self.is_task, "note_type": self.note_type or "note", "task_kind": self.task_kind, - "created_at": self.created_at.isoformat(), - "updated_at": self.updated_at.isoformat(), + "created_at": iso(self.created_at), + "updated_at": iso(self.updated_at), } diff --git a/src/scribe/models/note_draft.py b/src/scribe/models/note_draft.py index 5ffbc1e..898cb80 100644 --- a/src/scribe/models/note_draft.py +++ b/src/scribe/models/note_draft.py @@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Integer, Text from sqlalchemy.orm import Mapped, mapped_column from scribe.models import Base -from scribe.models.base import TimestampMixin +from scribe.models.base import TimestampMixin, iso class NoteDraft(Base, TimestampMixin): @@ -25,6 +25,6 @@ class NoteDraft(Base, TimestampMixin): "original_body": self.original_body, "instruction": self.instruction, "scope": self.scope, - "created_at": self.created_at.isoformat(), - "updated_at": self.updated_at.isoformat(), + "created_at": iso(self.created_at), + "updated_at": iso(self.updated_at), } diff --git a/src/scribe/models/note_supersession.py b/src/scribe/models/note_supersession.py index 127ccec..635f569 100644 --- a/src/scribe/models/note_supersession.py +++ b/src/scribe/models/note_supersession.py @@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column from scribe.models import Base -from scribe.models.base import CreatedAtMixin +from scribe.models.base import CreatedAtMixin, iso class NoteSupersession(Base, CreatedAtMixin): @@ -66,5 +66,5 @@ class NoteSupersession(Base, CreatedAtMixin): "id": self.id, "superseder_id": self.superseder_id, "superseded_id": self.superseded_id, - "created_at": self.created_at.isoformat() if self.created_at else None, + "created_at": iso(self.created_at), } diff --git a/src/scribe/models/note_usage.py b/src/scribe/models/note_usage.py index 85eb5d4..433cccb 100644 --- a/src/scribe/models/note_usage.py +++ b/src/scribe/models/note_usage.py @@ -1,15 +1,14 @@ -from datetime import datetime, timezone - -from sqlalchemy import DateTime, Index, Integer, Text +from sqlalchemy import Index, Integer, Text from sqlalchemy.orm import Mapped, mapped_column from scribe.models import Base +from scribe.models.base import CreatedAtMixin, iso SURFACED = "surfaced" PULLED = "pulled" -class NoteUsageEvent(Base): +class NoteUsageEvent(Base, CreatedAtMixin): """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 @@ -44,9 +43,6 @@ class NoteUsageEvent(Base): __tablename__ = "note_usage_events" 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) note_id: Mapped[int] = mapped_column(Integer, nullable=False) # 'surfaced' | 'pulled' @@ -81,7 +77,7 @@ class NoteUsageEvent(Base): def to_dict(self) -> dict: return { "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, "note_id": self.note_id, "event": self.event, diff --git a/src/scribe/models/note_version.py b/src/scribe/models/note_version.py index 86cd62f..04e395d 100644 --- a/src/scribe/models/note_version.py +++ b/src/scribe/models/note_version.py @@ -2,7 +2,7 @@ from sqlalchemy import ARRAY, ForeignKey, Integer, Text from sqlalchemy.orm import Mapped, mapped_column from scribe.models import Base -from scribe.models.base import CreatedAtMixin +from scribe.models.base import CreatedAtMixin, iso class NoteVersion(Base, CreatedAtMixin): @@ -26,7 +26,7 @@ class NoteVersion(Base, CreatedAtMixin): "tags": self.tags or [], "pin_kind": self.pin_kind, "pin_label": self.pin_label, - "created_at": self.created_at.isoformat(), + "created_at": iso(self.created_at), } if include_body: d["body"] = self.body diff --git a/src/scribe/models/notification.py b/src/scribe/models/notification.py index 05a5c35..ae455b1 100644 --- a/src/scribe/models/notification.py +++ b/src/scribe/models/notification.py @@ -5,7 +5,7 @@ from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column from scribe.models import Base -from scribe.models.base import CreatedAtMixin +from scribe.models.base import CreatedAtMixin, iso class Notification(Base, CreatedAtMixin): @@ -26,6 +26,6 @@ class Notification(Base, CreatedAtMixin): "user_id": self.user_id, "type": self.type, "payload": self.payload, - "read_at": self.read_at.isoformat() if self.read_at else None, - "created_at": self.created_at.isoformat(), + "read_at": iso(self.read_at), + "created_at": iso(self.created_at), } diff --git a/src/scribe/models/password_reset.py b/src/scribe/models/password_reset.py index 70add83..811902c 100644 --- a/src/scribe/models/password_reset.py +++ b/src/scribe/models/password_reset.py @@ -4,9 +4,10 @@ from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Text from sqlalchemy.orm import Mapped, mapped_column from scribe.models import Base +from scribe.models.base import CreatedAtMixin -class PasswordResetToken(Base): +class PasswordResetToken(Base, CreatedAtMixin): __tablename__ = "password_reset_tokens" 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) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), 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__ = ( Index("ix_password_reset_tokens_token_hash", "token_hash"), diff --git a/src/scribe/models/project.py b/src/scribe/models/project.py index 1d37c6e..3297106 100644 --- a/src/scribe/models/project.py +++ b/src/scribe/models/project.py @@ -2,7 +2,7 @@ import enum from sqlalchemy import BigInteger, ForeignKey, Integer, Text from sqlalchemy.orm import Mapped, mapped_column 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): @@ -48,6 +48,6 @@ class Project(Base, TimestampMixin, SoftDeleteMixin): "color": self.color, "design_system_id": self.design_system_id, "forge_connection_id": self.forge_connection_id, - "created_at": self.created_at.isoformat(), - "updated_at": self.updated_at.isoformat(), + "created_at": iso(self.created_at), + "updated_at": iso(self.updated_at), } diff --git a/src/scribe/models/repo_binding.py b/src/scribe/models/repo_binding.py index b5e8039..8686bcf 100644 --- a/src/scribe/models/repo_binding.py +++ b/src/scribe/models/repo_binding.py @@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Integer, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column from scribe.models import Base -from scribe.models.base import TimestampMixin +from scribe.models.base import TimestampMixin, iso class RepoBinding(Base, TimestampMixin): @@ -35,6 +35,6 @@ class RepoBinding(Base, TimestampMixin): "user_id": self.user_id, "project_id": self.project_id, "repo_key": self.repo_key, - "created_at": self.created_at.isoformat(), - "updated_at": self.updated_at.isoformat(), + "created_at": iso(self.created_at), + "updated_at": iso(self.updated_at), } diff --git a/src/scribe/models/retrieval_log.py b/src/scribe/models/retrieval_log.py index 6630364..ba49df4 100644 --- a/src/scribe/models/retrieval_log.py +++ b/src/scribe/models/retrieval_log.py @@ -5,6 +5,7 @@ from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column from scribe.models import Base +from scribe.models.base import iso class RetrievalLog(Base): @@ -23,6 +24,9 @@ class RetrievalLog(Base): __tablename__ = "retrieval_logs" 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( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) ) @@ -54,7 +58,7 @@ class RetrievalLog(Base): def to_dict(self) -> dict: return { "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, "source": self.source, "query": self.query, diff --git a/src/scribe/models/rulebook.py b/src/scribe/models/rulebook.py index 6b0813e..7a1cdf5 100644 --- a/src/scribe/models/rulebook.py +++ b/src/scribe/models/rulebook.py @@ -4,10 +4,10 @@ from sqlalchemy import BigInteger, Boolean, Column, DateTime, ForeignKey, Index, from sqlalchemy.orm import Mapped, mapped_column 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" id: Mapped[int] = mapped_column(BigInteger, primary_key=True) @@ -19,14 +19,6 @@ class Rulebook(Base, SoftDeleteMixin): 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 { @@ -35,12 +27,12 @@ class Rulebook(Base, SoftDeleteMixin): "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, + "created_at": iso(self.created_at), + "updated_at": iso(self.updated_at), } -class RulebookTopic(Base, SoftDeleteMixin): +class RulebookTopic(Base, TimestampMixin, 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. @@ -58,14 +50,6 @@ class RulebookTopic(Base, SoftDeleteMixin): 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 { @@ -74,12 +58,12 @@ class RulebookTopic(Base, SoftDeleteMixin): "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, + "created_at": iso(self.created_at), + "updated_at": iso(self.updated_at), } -class Rule(Base, SoftDeleteMixin): +class Rule(Base, TimestampMixin, SoftDeleteMixin): __tablename__ = "rules" # Partial unique: title unique among LIVE rules in a topic (soft-deleted # 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) 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 { @@ -128,8 +104,8 @@ class Rule(Base, SoftDeleteMixin): "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, + "created_at": iso(self.created_at), + "updated_at": iso(self.updated_at), } diff --git a/src/scribe/models/share.py b/src/scribe/models/share.py index 522a728..038e6ee 100644 --- a/src/scribe/models/share.py +++ b/src/scribe/models/share.py @@ -2,7 +2,7 @@ from sqlalchemy import CheckConstraint, ForeignKey, Integer, Text from sqlalchemy.orm import Mapped, mapped_column from scribe.models import Base -from scribe.models.base import TimestampMixin +from scribe.models.base import TimestampMixin, iso class ProjectShare(Base, TimestampMixin): @@ -37,8 +37,8 @@ class ProjectShare(Base, TimestampMixin): "shared_with_group_id": self.shared_with_group_id, "permission": self.permission, "invited_by": self.invited_by, - "created_at": self.created_at.isoformat(), - "updated_at": self.updated_at.isoformat(), + "created_at": iso(self.created_at), + "updated_at": iso(self.updated_at), } @@ -74,6 +74,6 @@ class NoteShare(Base, TimestampMixin): "shared_with_group_id": self.shared_with_group_id, "permission": self.permission, "invited_by": self.invited_by, - "created_at": self.created_at.isoformat(), - "updated_at": self.updated_at.isoformat(), + "created_at": iso(self.created_at), + "updated_at": iso(self.updated_at), } diff --git a/src/scribe/models/system.py b/src/scribe/models/system.py index a47416e..1def12f 100644 --- a/src/scribe/models/system.py +++ b/src/scribe/models/system.py @@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Index, Integer, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column 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): @@ -44,8 +44,8 @@ class System(Base, TimestampMixin, SoftDeleteMixin): "color": self.color, "status": self.status, "order_index": self.order_index, - "created_at": self.created_at.isoformat(), - "updated_at": self.updated_at.isoformat(), + "created_at": iso(self.created_at), + "updated_at": iso(self.updated_at), } diff --git a/src/scribe/models/task_log.py b/src/scribe/models/task_log.py index 0866ea8..2820613 100644 --- a/src/scribe/models/task_log.py +++ b/src/scribe/models/task_log.py @@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Integer, Text from sqlalchemy.orm import Mapped, mapped_column from scribe.models import Base -from scribe.models.base import TimestampMixin +from scribe.models.base import TimestampMixin, iso class TaskLog(Base, TimestampMixin): @@ -21,6 +21,6 @@ class TaskLog(Base, TimestampMixin): "user_id": self.user_id, "content": self.content, "duration_minutes": self.duration_minutes, - "created_at": self.created_at.isoformat(), - "updated_at": self.updated_at.isoformat(), + "created_at": iso(self.created_at), + "updated_at": iso(self.updated_at), } diff --git a/src/scribe/models/user.py b/src/scribe/models/user.py index 6a295cf..ea98372 100644 --- a/src/scribe/models/user.py +++ b/src/scribe/models/user.py @@ -2,7 +2,7 @@ from sqlalchemy import Index, Integer, Text from sqlalchemy.orm import Mapped, mapped_column from scribe.models import Base -from scribe.models.base import CreatedAtMixin +from scribe.models.base import CreatedAtMixin, iso class User(Base, CreatedAtMixin): @@ -26,6 +26,6 @@ class User(Base, CreatedAtMixin): "username": self.username, "email": self.email, "role": self.role, - "created_at": self.created_at.isoformat(), + "created_at": iso(self.created_at), "has_password": self.password_hash is not None, }