The Office (workspace D-64..D-72) needs a store for Notes, Tasks,
Decisions, Questions and Rejected options. D-68 puts it here, in
core-api's own database, behind its own auth.
Migration 005 lands the whole schema now, by hand (down_revision="004";
the chain has a hash-named revision between 003 and 004, confirmed
before trusting the numbering): book_records (kind, status validated
per kind in the service, scope work/personal, a generated tsvector
search column + GIN index), book_tags, book_record_links, book_attachments
(table only, no file handling yet), and book_events — append-only via a
database trigger that raises on UPDATE and DELETE for every role,
including the owning application.
Routes this round (D-70's core verbs), every one behind
require_caller("office", "user"), writes with identity_required=True,
every change writing a book_events row: add, show, list (the nine
views), find (full-text), change, close (task done/dropped; question
resolved via a `resolves` link into `settled`; decision superseded via
a `supersedes` link into `superseded`), link/unlink, tag/untag, history.
Append, remind, reschedule, undo, purge, redact, pin, check, snooze,
archive/trash/restore, pause/resume, due, next, batch calls and
idempotency keys are explicitly not this round — the schema already
carries what they need.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
249 lines
9.5 KiB
Python
249 lines
9.5 KiB
Python
"""
|
|
Book Domain Models
|
|
|
|
The Book holds Notes, Tasks, Decisions, Questions and Rejected options for
|
|
the Office (workspace D-68, D-69, D-70). SQLAlchemy 2.0 Mapped/mapped_column
|
|
style, timestamptz, following src/domains/auth/models.py.
|
|
|
|
Table prefix is `book_` (D-68). `book_events` is append-only — migration 005
|
|
adds a trigger that raises on UPDATE and DELETE, enforced at the database,
|
|
not here.
|
|
"""
|
|
import uuid
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
from sqlalchemy import (
|
|
Boolean,
|
|
Computed,
|
|
DateTime,
|
|
ForeignKey,
|
|
Integer,
|
|
String,
|
|
Text,
|
|
UniqueConstraint,
|
|
func,
|
|
)
|
|
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR, UUID
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from src.shared.database import Base
|
|
|
|
# Kinds and their valid statuses. Enforced in the service layer (per kind),
|
|
# not as a database CHECK — the valid status set depends on the kind, which
|
|
# a single-column CHECK cannot express without a trigger.
|
|
RECORD_KINDS = ("note", "task", "decision", "question", "rejected")
|
|
|
|
STATUSES_BY_KIND = {
|
|
"note": {"active"},
|
|
"task": {"inbox", "open", "waiting", "someday", "done", "dropped"},
|
|
"decision": {"proposed", "accepted", "superseded"},
|
|
"question": {"open", "settled", "dropped"},
|
|
"rejected": {"rejected"},
|
|
}
|
|
|
|
DEFAULT_STATUS_BY_KIND = {
|
|
"note": "active",
|
|
"task": "inbox",
|
|
"decision": "proposed",
|
|
"question": "open",
|
|
"rejected": "rejected",
|
|
}
|
|
|
|
SCOPES = ("work", "personal")
|
|
DEFAULT_SCOPE = "personal"
|
|
|
|
LINK_RELATIONS = ("parent", "blocks", "resolves", "supersedes", "related")
|
|
|
|
|
|
class BookRecord(Base):
|
|
"""A Note, Task, Decision, Question or Rejected option."""
|
|
|
|
__tablename__ = "book_records"
|
|
__table_args__ = (
|
|
UniqueConstraint("source", "external_id", name="uq_book_records_source_external_id"),
|
|
)
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
primary_key=True,
|
|
default=uuid.uuid4,
|
|
)
|
|
kind: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
|
title: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
|
body: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
checklist: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
|
status: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
|
scope: Mapped[str] = mapped_column(String(10), nullable=False, default=DEFAULT_SCOPE)
|
|
priority: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
|
pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
energy: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
|
|
estimate_minutes: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
|
next_step: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
start_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
due_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
|
all_day: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
remind_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
recurrence: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
|
recurrence_mode: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
|
remind_to: Mapped[Optional[str]] = mapped_column(String(63), nullable=True)
|
|
last_touched_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
server_default=func.now(),
|
|
nullable=False,
|
|
)
|
|
archived_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
trashed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
paused_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
paused_statuses: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
|
source: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
|
external_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
|
colour: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
|
created_by: Mapped[str] = mapped_column(String(63), nullable=False)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
server_default=func.now(),
|
|
nullable=False,
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
server_default=func.now(),
|
|
onupdate=func.now(),
|
|
nullable=False,
|
|
)
|
|
# Generated by migration 005 (`to_tsvector('simple', ...)`, GIN-indexed).
|
|
# 'simple' because the household writes mixed Dutch and English — a
|
|
# language-specific config would stem one of the two badly. `Computed`
|
|
# mirrors the migration's SQL so SQLAlchemy knows this column is
|
|
# database-generated and excludes it from INSERT/UPDATE — without it,
|
|
# the ORM tries to write NULL into a GENERATED ALWAYS column and
|
|
# Postgres rejects the insert outright.
|
|
search: Mapped[Optional[str]] = mapped_column(
|
|
TSVECTOR,
|
|
Computed("to_tsvector('simple', coalesce(title, '') || ' ' || body)", persisted=True),
|
|
nullable=True,
|
|
)
|
|
|
|
# Deliberately no `tags` relationship here. The service always queries
|
|
# book_tags explicitly (`BookService._tags_for`) rather than traversing
|
|
# an ORM relationship — partly because async SQLAlchemy can't lazy-load
|
|
# on an unloaded attribute without a greenlet error, but mainly because
|
|
# a relationship named `tags` collides with the response schema's
|
|
# `tags: List[str]` field: `BookRecordResponse.model_validate(record,
|
|
# from_attributes=True)` would read the relationship (a list of BookTag
|
|
# ORM objects) instead of the list of strings the controller builds
|
|
# separately, and fail validation. Cascade delete is handled by the
|
|
# column's own `ondelete="CASCADE"`, so the relationship bought nothing.
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<BookRecord {self.kind} {self.id}>"
|
|
|
|
|
|
class BookTag(Base):
|
|
"""A free tag on a record. `work`/`personal` live on `scope`, not here."""
|
|
|
|
__tablename__ = "book_tags"
|
|
|
|
record_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("book_records.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
)
|
|
tag: Mapped[str] = mapped_column(String(63), primary_key=True)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<BookTag {self.tag} on {self.record_id}>"
|
|
|
|
|
|
class BookRecordLink(Base):
|
|
"""A directed relation between two records."""
|
|
|
|
__tablename__ = "book_record_links"
|
|
|
|
from_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("book_records.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
)
|
|
relation: Mapped[str] = mapped_column(String(20), primary_key=True)
|
|
to_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("book_records.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
)
|
|
created_by: Mapped[str] = mapped_column(String(63), nullable=False)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
server_default=func.now(),
|
|
nullable=False,
|
|
)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<BookRecordLink {self.from_id} {self.relation} {self.to_id}>"
|
|
|
|
|
|
class BookAttachment(Base):
|
|
"""A file attached to a record. Table only — no file handling yet."""
|
|
|
|
__tablename__ = "book_attachments"
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
primary_key=True,
|
|
default=uuid.uuid4,
|
|
)
|
|
record_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("book_records.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
path: Mapped[str] = mapped_column(String(1000), nullable=False)
|
|
mime: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
|
size: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
server_default=func.now(),
|
|
nullable=False,
|
|
)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<BookAttachment {self.path} on {self.record_id}>"
|
|
|
|
|
|
class BookEvent(Base):
|
|
"""
|
|
An append-only audit row for every change to a record.
|
|
|
|
Migration 005 adds a trigger that raises on UPDATE and DELETE against
|
|
this table — enforced for every role, the owning application included.
|
|
`redact` (D-70 rule 4) is the one sanctioned exception, and it is a
|
|
later round; nothing here weakens the trigger.
|
|
"""
|
|
|
|
__tablename__ = "book_events"
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
primary_key=True,
|
|
default=uuid.uuid4,
|
|
)
|
|
record_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("book_records.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
server_default=func.now(),
|
|
nullable=False,
|
|
)
|
|
identity: Mapped[str] = mapped_column(String(63), nullable=False)
|
|
client: Mapped[Optional[str]] = mapped_column(String(63), nullable=True)
|
|
action: Mapped[str] = mapped_column(String(20), nullable=False)
|
|
diff: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<BookEvent {self.action} on {self.record_id} by {self.identity}>"
|