feat(book): add the Book domain — notes, tasks, decisions, questions, rejected (T-1)
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>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Book Domain
|
||||
|
||||
The household's Notes, Tasks, Decisions, Questions and Rejected options
|
||||
(workspace D-68, D-69, D-70). Served under /book/*.
|
||||
"""
|
||||
from src.domains.book.controller import book_controller
|
||||
|
||||
__all__ = ["book_controller"]
|
||||
@@ -0,0 +1,316 @@
|
||||
"""
|
||||
Book Controller
|
||||
|
||||
The core Book verbs (D-70): add, show, list, find, change, close,
|
||||
link/unlink, tag/untag, history. Every route requires a caller (office or
|
||||
user); writes additionally require an identity (T-1).
|
||||
"""
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from src.domains.auth.callers import Caller, require_caller
|
||||
from src.domains.book.schemas import (
|
||||
BookCloseRequest,
|
||||
BookEventResponse,
|
||||
BookHistoryResponse,
|
||||
BookLinkRequest,
|
||||
BookLinkResponse,
|
||||
BookRecordCreate,
|
||||
BookRecordDetailResponse,
|
||||
BookRecordListResponse,
|
||||
BookRecordResponse,
|
||||
BookRecordUpdate,
|
||||
BookTagRequest,
|
||||
ListView,
|
||||
)
|
||||
from src.domains.book.service import BookError, get_book_service
|
||||
from src.shared.base import BaseController
|
||||
from src.shared.database import get_async_session
|
||||
from src.shared.logging import get_logger
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
require_book_reader = require_caller("office", "user")
|
||||
require_book_writer = require_caller("office", "user", identity_required=True)
|
||||
|
||||
|
||||
def _record_response(record, tags) -> BookRecordResponse:
|
||||
schema = BookRecordResponse.model_validate(record, from_attributes=True)
|
||||
return schema.model_copy(update={"tags": tags})
|
||||
|
||||
|
||||
def _link_response(link) -> BookLinkResponse:
|
||||
return BookLinkResponse.model_validate(link, from_attributes=True)
|
||||
|
||||
|
||||
def _detail_response(record, tags, links, backlinks) -> BookRecordDetailResponse:
|
||||
base = BookRecordResponse.model_validate(record, from_attributes=True).model_dump()
|
||||
base["tags"] = tags
|
||||
return BookRecordDetailResponse(
|
||||
**base,
|
||||
links=[_link_response(link) for link in links],
|
||||
backlinks=[_link_response(link) for link in backlinks],
|
||||
)
|
||||
|
||||
|
||||
def _event_response(event) -> BookEventResponse:
|
||||
return BookEventResponse.model_validate(event, from_attributes=True)
|
||||
|
||||
|
||||
class BookController(BaseController):
|
||||
"""Controller for the Book domain."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(prefix="/book", tags=["Book"])
|
||||
|
||||
def create_router(self) -> APIRouter:
|
||||
router = APIRouter(prefix=self.prefix, tags=self.tags)
|
||||
service = get_book_service()
|
||||
|
||||
# =====================================================================
|
||||
# add
|
||||
# =====================================================================
|
||||
|
||||
@router.post(
|
||||
"/records",
|
||||
response_model=BookRecordResponse,
|
||||
status_code=201,
|
||||
summary="Add a record to the Book",
|
||||
)
|
||||
async def add_record(
|
||||
data: BookRecordCreate,
|
||||
caller: Caller = Depends(require_book_writer),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
record, tags = await service.add_record(session, data, caller)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
return _record_response(record, tags)
|
||||
|
||||
# =====================================================================
|
||||
# list
|
||||
# =====================================================================
|
||||
|
||||
@router.get(
|
||||
"/records",
|
||||
response_model=BookRecordListResponse,
|
||||
summary="List records",
|
||||
)
|
||||
async def list_records(
|
||||
view: Optional[ListView] = Query(None, description="inbox, today, upcoming, waiting, someday, pinned, archive, trash, done"),
|
||||
tag: Optional[str] = Query(None),
|
||||
scope: Optional[str] = Query(None),
|
||||
parent: Optional[uuid.UUID] = Query(None),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
caller: Caller = Depends(require_book_reader),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
results, total = await service.list_records(
|
||||
session, view=view, tag=tag, scope=scope, parent=parent, limit=limit, offset=offset
|
||||
)
|
||||
return BookRecordListResponse(
|
||||
records=[_record_response(record, tags) for record, tags in results],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
# =====================================================================
|
||||
# find — declared before /{record_id} so "find" is never parsed as an id
|
||||
# =====================================================================
|
||||
|
||||
@router.get(
|
||||
"/records/find",
|
||||
response_model=BookRecordListResponse,
|
||||
summary="Full-text search the Book",
|
||||
)
|
||||
async def find_records(
|
||||
q: str = Query(..., min_length=1),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
caller: Caller = Depends(require_book_reader),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
results, total = await service.find_records(session, q=q, limit=limit, offset=offset)
|
||||
return BookRecordListResponse(
|
||||
records=[_record_response(record, tags) for record, tags in results],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
# =====================================================================
|
||||
# show
|
||||
# =====================================================================
|
||||
|
||||
@router.get(
|
||||
"/records/{record_id}",
|
||||
response_model=BookRecordDetailResponse,
|
||||
summary="Show a record, its links and its tags",
|
||||
)
|
||||
async def show_record(
|
||||
record_id: uuid.UUID,
|
||||
caller: Caller = Depends(require_book_reader),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
record, tags, links, backlinks = await service.show_record(session, record_id)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
return _detail_response(record, tags, links, backlinks)
|
||||
|
||||
# =====================================================================
|
||||
# change
|
||||
# =====================================================================
|
||||
|
||||
@router.patch(
|
||||
"/records/{record_id}",
|
||||
response_model=BookRecordResponse,
|
||||
summary="Change a record",
|
||||
)
|
||||
async def change_record(
|
||||
record_id: uuid.UUID,
|
||||
data: BookRecordUpdate,
|
||||
caller: Caller = Depends(require_book_writer),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
record, tags = await service.change_record(session, record_id, data, caller)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
return _record_response(record, tags)
|
||||
|
||||
# =====================================================================
|
||||
# close
|
||||
# =====================================================================
|
||||
|
||||
@router.post(
|
||||
"/records/{record_id}/close",
|
||||
response_model=BookRecordResponse,
|
||||
summary="Close a record (task done/dropped, question resolved, decision superseded)",
|
||||
)
|
||||
async def close_record(
|
||||
record_id: uuid.UUID,
|
||||
data: BookCloseRequest,
|
||||
caller: Caller = Depends(require_book_writer),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
record, tags = await service.close_record(session, record_id, data, caller)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
return _record_response(record, tags)
|
||||
|
||||
# =====================================================================
|
||||
# link / unlink
|
||||
# =====================================================================
|
||||
|
||||
@router.post(
|
||||
"/records/{record_id}/link",
|
||||
response_model=BookRecordResponse,
|
||||
summary="Link a record to another",
|
||||
)
|
||||
async def link_record(
|
||||
record_id: uuid.UUID,
|
||||
data: BookLinkRequest,
|
||||
caller: Caller = Depends(require_book_writer),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
record, tags = await service.link_records(
|
||||
session, record_id, data.to_id, data.relation, caller
|
||||
)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
return _record_response(record, tags)
|
||||
|
||||
@router.post(
|
||||
"/records/{record_id}/unlink",
|
||||
response_model=BookRecordResponse,
|
||||
summary="Remove a link between two records",
|
||||
)
|
||||
async def unlink_record(
|
||||
record_id: uuid.UUID,
|
||||
data: BookLinkRequest,
|
||||
caller: Caller = Depends(require_book_writer),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
record, tags = await service.unlink_records(
|
||||
session, record_id, data.to_id, data.relation, caller
|
||||
)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
return _record_response(record, tags)
|
||||
|
||||
# =====================================================================
|
||||
# tag / untag
|
||||
# =====================================================================
|
||||
|
||||
@router.post(
|
||||
"/records/{record_id}/tag",
|
||||
response_model=BookRecordResponse,
|
||||
summary="Tag a record",
|
||||
)
|
||||
async def tag_record(
|
||||
record_id: uuid.UUID,
|
||||
data: BookTagRequest,
|
||||
caller: Caller = Depends(require_book_writer),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
record, tags = await service.tag_record(session, record_id, data.tag, caller)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
return _record_response(record, tags)
|
||||
|
||||
@router.post(
|
||||
"/records/{record_id}/untag",
|
||||
response_model=BookRecordResponse,
|
||||
summary="Remove a tag from a record",
|
||||
)
|
||||
async def untag_record(
|
||||
record_id: uuid.UUID,
|
||||
data: BookTagRequest,
|
||||
caller: Caller = Depends(require_book_writer),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
record, tags = await service.untag_record(session, record_id, data.tag, caller)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
return _record_response(record, tags)
|
||||
|
||||
# =====================================================================
|
||||
# history
|
||||
# =====================================================================
|
||||
|
||||
@router.get(
|
||||
"/records/{record_id}/history",
|
||||
response_model=BookHistoryResponse,
|
||||
summary="The append-only event history for a record",
|
||||
)
|
||||
async def record_history(
|
||||
record_id: uuid.UUID,
|
||||
caller: Caller = Depends(require_book_reader),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
events = await service.get_history(session, record_id)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
return BookHistoryResponse(
|
||||
record_id=record_id,
|
||||
events=[_event_response(event) for event in events],
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
# Create controller instance
|
||||
book_controller = BookController()
|
||||
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
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}>"
|
||||
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
Book Domain Schemas
|
||||
|
||||
Pydantic request/response payloads for the Book (D-68, D-70). This round
|
||||
ships `add`, `show`, `list`, `find`, `change`, `close`, `link`/`unlink`,
|
||||
`tag`/`untag`, `history` — the verbs the Office needs next round are not
|
||||
schematized here (dossier: append, remind, reschedule, undo, purge, redact,
|
||||
pin, check, snooze, archive/trash/restore, pause/resume, due, next, batch,
|
||||
idempotency keys).
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, List, Literal, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from src.shared.base import BaseSchema
|
||||
|
||||
RecordKind = Literal["note", "task", "decision", "question", "rejected"]
|
||||
RecordScope = Literal["work", "personal"]
|
||||
RecordEnergy = Literal["low", "medium", "high"]
|
||||
RecurrenceMode = Literal["schedule", "after_completion"]
|
||||
LinkRelation = Literal["parent", "blocks", "resolves", "supersedes", "related"]
|
||||
ListView = Literal[
|
||||
"inbox", "today", "upcoming", "waiting", "someday",
|
||||
"pinned", "archive", "trash", "done",
|
||||
]
|
||||
|
||||
|
||||
class BookChecklistItem(BaseSchema):
|
||||
"""One line of a record's checklist."""
|
||||
|
||||
text: str
|
||||
checked: bool = False
|
||||
position: int = 0
|
||||
|
||||
|
||||
class BookRecordCreate(BaseSchema):
|
||||
"""`add` — kind defaults to a Note so text-only capture costs nothing (D-72)."""
|
||||
|
||||
kind: RecordKind = "note"
|
||||
title: Optional[str] = Field(None, max_length=500)
|
||||
body: str = ""
|
||||
checklist: List[BookChecklistItem] = Field(default_factory=list)
|
||||
status: Optional[str] = None
|
||||
scope: RecordScope = "personal"
|
||||
priority: Optional[int] = None
|
||||
pinned: bool = False
|
||||
energy: Optional[RecordEnergy] = None
|
||||
estimate_minutes: Optional[int] = None
|
||||
next_step: Optional[str] = None
|
||||
start_at: Optional[datetime] = None
|
||||
due_at: Optional[datetime] = None
|
||||
all_day: bool = False
|
||||
remind_at: Optional[datetime] = None
|
||||
recurrence: Optional[str] = None
|
||||
recurrence_mode: Optional[RecurrenceMode] = None
|
||||
remind_to: Optional[str] = None
|
||||
source: Optional[str] = None
|
||||
external_id: Optional[str] = None
|
||||
colour: Optional[str] = None
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
parent: Optional[uuid.UUID] = Field(
|
||||
None, description="Convenience: also create a `parent` link to this record"
|
||||
)
|
||||
|
||||
|
||||
class BookRecordUpdate(BaseSchema):
|
||||
"""`change` — every field optional; only what is set is applied."""
|
||||
|
||||
kind: Optional[RecordKind] = None
|
||||
title: Optional[str] = Field(None, max_length=500)
|
||||
body: Optional[str] = None
|
||||
checklist: Optional[List[BookChecklistItem]] = None
|
||||
status: Optional[str] = None
|
||||
scope: Optional[RecordScope] = None
|
||||
priority: Optional[int] = None
|
||||
pinned: Optional[bool] = None
|
||||
energy: Optional[RecordEnergy] = None
|
||||
estimate_minutes: Optional[int] = None
|
||||
next_step: Optional[str] = None
|
||||
start_at: Optional[datetime] = None
|
||||
due_at: Optional[datetime] = None
|
||||
all_day: Optional[bool] = None
|
||||
remind_at: Optional[datetime] = None
|
||||
recurrence: Optional[str] = None
|
||||
recurrence_mode: Optional[RecurrenceMode] = None
|
||||
remind_to: Optional[str] = None
|
||||
source: Optional[str] = None
|
||||
external_id: Optional[str] = None
|
||||
colour: Optional[str] = None
|
||||
|
||||
|
||||
class BookCloseRequest(BaseSchema):
|
||||
"""
|
||||
`close` — the field that applies depends on the record's kind:
|
||||
- task: `outcome` ("done" or "dropped")
|
||||
- question: `resolved_by` (the Decision id that resolves it); sets `settled`
|
||||
- decision: `superseded_by` (the new Decision id); the old one becomes `superseded`
|
||||
"""
|
||||
|
||||
outcome: Optional[Literal["done", "dropped"]] = None
|
||||
resolved_by: Optional[uuid.UUID] = None
|
||||
superseded_by: Optional[uuid.UUID] = None
|
||||
|
||||
|
||||
class BookLinkRequest(BaseSchema):
|
||||
"""`link` / `unlink` — direction is from the record in the path to `to_id`."""
|
||||
|
||||
to_id: uuid.UUID
|
||||
relation: LinkRelation
|
||||
|
||||
|
||||
class BookTagRequest(BaseSchema):
|
||||
"""`tag` / `untag`."""
|
||||
|
||||
tag: str = Field(..., min_length=1, max_length=63)
|
||||
|
||||
|
||||
class BookLinkResponse(BaseSchema):
|
||||
from_id: uuid.UUID
|
||||
relation: str
|
||||
to_id: uuid.UUID
|
||||
created_by: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class BookRecordResponse(BaseSchema):
|
||||
"""Every write returns the stored record (D-70 rule 7)."""
|
||||
|
||||
id: uuid.UUID
|
||||
kind: str
|
||||
title: Optional[str]
|
||||
body: str
|
||||
checklist: List[dict] = Field(default_factory=list)
|
||||
status: str
|
||||
scope: str
|
||||
priority: Optional[int]
|
||||
pinned: bool
|
||||
energy: Optional[str]
|
||||
estimate_minutes: Optional[int]
|
||||
next_step: Optional[str]
|
||||
start_at: Optional[datetime]
|
||||
due_at: Optional[datetime]
|
||||
all_day: bool
|
||||
remind_at: Optional[datetime]
|
||||
recurrence: Optional[str]
|
||||
recurrence_mode: Optional[str]
|
||||
remind_to: Optional[str]
|
||||
last_touched_at: datetime
|
||||
archived_at: Optional[datetime]
|
||||
trashed_at: Optional[datetime]
|
||||
paused_at: Optional[datetime]
|
||||
paused_statuses: Optional[dict]
|
||||
source: Optional[str]
|
||||
external_id: Optional[str]
|
||||
colour: Optional[str]
|
||||
created_by: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BookRecordDetailResponse(BookRecordResponse):
|
||||
"""`show` — the record plus its outgoing links and backlinks."""
|
||||
|
||||
links: List[BookLinkResponse] = Field(default_factory=list)
|
||||
backlinks: List[BookLinkResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BookRecordListResponse(BaseSchema):
|
||||
records: List[BookRecordResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class BookEventResponse(BaseSchema):
|
||||
id: uuid.UUID
|
||||
record_id: uuid.UUID
|
||||
at: datetime
|
||||
identity: str
|
||||
client: Optional[str]
|
||||
action: str
|
||||
diff: dict
|
||||
|
||||
|
||||
class BookHistoryResponse(BaseSchema):
|
||||
record_id: uuid.UUID
|
||||
events: List[BookEventResponse]
|
||||
@@ -0,0 +1,500 @@
|
||||
"""
|
||||
Book Domain Service
|
||||
|
||||
Business logic for the Book — Notes, Tasks, Decisions, Questions and
|
||||
Rejected options (D-68, D-70). Every mutating method stamps a `book_events`
|
||||
row via `_write_event`; the table itself is append-only (migration 005's
|
||||
trigger), so this module never updates or deletes an event.
|
||||
|
||||
Methods take `session: AsyncSession` per call rather than storing it on
|
||||
`self`, following `src/domains/dashboard/service.py`.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Tuple
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.domains.auth.callers import Caller
|
||||
from src.domains.book.models import (
|
||||
DEFAULT_STATUS_BY_KIND,
|
||||
STATUSES_BY_KIND,
|
||||
BookEvent,
|
||||
BookRecord,
|
||||
BookRecordLink,
|
||||
BookTag,
|
||||
)
|
||||
from src.domains.book.schemas import BookCloseRequest, BookRecordCreate, BookRecordUpdate
|
||||
from src.shared.config import get_settings
|
||||
from src.shared.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class BookError(Exception):
|
||||
"""Raised for a Book-domain failure; the controller maps it to a response."""
|
||||
|
||||
def __init__(self, code: str, status_code: int = 400):
|
||||
self.code = code
|
||||
self.status_code = status_code
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
class BookService:
|
||||
"""Service for Book operations."""
|
||||
|
||||
# =========================================================================
|
||||
# Internal helpers
|
||||
# =========================================================================
|
||||
|
||||
async def _get_or_404(self, session: AsyncSession, record_id) -> BookRecord:
|
||||
record = await session.get(BookRecord, record_id)
|
||||
if record is None:
|
||||
raise BookError("not_found", 404)
|
||||
return record
|
||||
|
||||
async def _tags_for(self, session: AsyncSession, record_id) -> List[str]:
|
||||
stmt = select(BookTag.tag).where(BookTag.record_id == record_id).order_by(BookTag.tag)
|
||||
result = await session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def _write_event(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
record_id,
|
||||
caller: Caller,
|
||||
action: str,
|
||||
diff: dict,
|
||||
) -> None:
|
||||
event = BookEvent(
|
||||
record_id=record_id,
|
||||
identity=caller.identity,
|
||||
client=caller.client,
|
||||
action=action,
|
||||
diff=diff,
|
||||
)
|
||||
session.add(event)
|
||||
await session.flush()
|
||||
|
||||
def _validate_status(self, kind: str, status: Optional[str]) -> str:
|
||||
"""Resolve and validate a status for `kind`; defaults when unset."""
|
||||
resolved = status or DEFAULT_STATUS_BY_KIND[kind]
|
||||
if resolved not in STATUSES_BY_KIND[kind]:
|
||||
raise BookError("invalid_status", 400)
|
||||
return resolved
|
||||
|
||||
def _touch(self, record: BookRecord) -> None:
|
||||
record.last_touched_at = datetime.now(timezone.utc)
|
||||
|
||||
# =========================================================================
|
||||
# add / show / list / find
|
||||
# =========================================================================
|
||||
|
||||
async def add_record(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
data: BookRecordCreate,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str]]:
|
||||
"""`add` — text-only input lands an unfiled Note (D-72)."""
|
||||
status = self._validate_status(data.kind, data.status)
|
||||
|
||||
record = BookRecord(
|
||||
kind=data.kind,
|
||||
title=data.title,
|
||||
body=data.body,
|
||||
checklist=[item.model_dump() for item in data.checklist],
|
||||
status=status,
|
||||
scope=data.scope,
|
||||
priority=data.priority,
|
||||
pinned=data.pinned,
|
||||
energy=data.energy,
|
||||
estimate_minutes=data.estimate_minutes,
|
||||
next_step=data.next_step,
|
||||
start_at=data.start_at,
|
||||
due_at=data.due_at,
|
||||
all_day=data.all_day,
|
||||
remind_at=data.remind_at,
|
||||
recurrence=data.recurrence,
|
||||
recurrence_mode=data.recurrence_mode,
|
||||
remind_to=data.remind_to,
|
||||
source=data.source,
|
||||
external_id=data.external_id,
|
||||
colour=data.colour,
|
||||
created_by=caller.identity,
|
||||
)
|
||||
session.add(record)
|
||||
await session.flush() # assign record.id
|
||||
|
||||
tag_names = list(dict.fromkeys(data.tags)) # de-dup, keep order
|
||||
for tag in tag_names:
|
||||
session.add(BookTag(record_id=record.id, tag=tag))
|
||||
|
||||
if data.parent is not None:
|
||||
session.add(BookRecordLink(
|
||||
from_id=record.id,
|
||||
relation="parent",
|
||||
to_id=data.parent,
|
||||
created_by=caller.identity,
|
||||
))
|
||||
|
||||
await self._write_event(session, record.id, caller, "add", {"kind": data.kind, "status": status})
|
||||
await session.commit()
|
||||
await session.refresh(record)
|
||||
|
||||
logger.info(f"Book record added: kind={data.kind} id={record.id} by={caller.identity}")
|
||||
return record, tag_names
|
||||
|
||||
async def show_record(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
record_id,
|
||||
) -> Tuple[BookRecord, List[str], List[BookRecordLink], List[BookRecordLink]]:
|
||||
"""`show` — the record, its tags, outgoing links and backlinks."""
|
||||
record = await self._get_or_404(session, record_id)
|
||||
tags = await self._tags_for(session, record_id)
|
||||
|
||||
links_stmt = select(BookRecordLink).where(BookRecordLink.from_id == record_id)
|
||||
links = list((await session.execute(links_stmt)).scalars().all())
|
||||
|
||||
backlinks_stmt = select(BookRecordLink).where(BookRecordLink.to_id == record_id)
|
||||
backlinks = list((await session.execute(backlinks_stmt)).scalars().all())
|
||||
|
||||
return record, tags, links, backlinks
|
||||
|
||||
async def list_records(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
view: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
scope: Optional[str] = None,
|
||||
parent: Optional[object] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> Tuple[List[Tuple[BookRecord, List[str]]], int]:
|
||||
"""
|
||||
`list` — the nine views of D-70. Status-named views (`inbox`,
|
||||
`waiting`, `someday`, `done`) filter on the matching task status and
|
||||
simply return nothing for kinds that never carry it. `today` and
|
||||
`upcoming` bucket on `due_at` in `book_timezone`; richer due/overdue
|
||||
handling is `due`/`next`, both next round.
|
||||
"""
|
||||
conditions = []
|
||||
|
||||
if view in ("inbox", "waiting", "someday", "done"):
|
||||
conditions.append(BookRecord.status == view)
|
||||
elif view == "pinned":
|
||||
conditions.append(BookRecord.pinned.is_(True))
|
||||
elif view == "archive":
|
||||
conditions.append(BookRecord.archived_at.isnot(None))
|
||||
elif view == "trash":
|
||||
conditions.append(BookRecord.trashed_at.isnot(None))
|
||||
elif view in ("today", "upcoming"):
|
||||
tz = ZoneInfo(get_settings().book_timezone)
|
||||
local_now = datetime.now(tz)
|
||||
day_start = local_now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
day_end = day_start.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
conditions.append(BookRecord.due_at.isnot(None))
|
||||
if view == "today":
|
||||
conditions.append(BookRecord.due_at >= day_start)
|
||||
conditions.append(BookRecord.due_at <= day_end)
|
||||
else:
|
||||
conditions.append(BookRecord.due_at > day_end)
|
||||
|
||||
if view not in ("archive", "trash"):
|
||||
conditions.append(BookRecord.archived_at.is_(None))
|
||||
conditions.append(BookRecord.trashed_at.is_(None))
|
||||
|
||||
if scope is not None:
|
||||
conditions.append(BookRecord.scope == scope)
|
||||
|
||||
if parent is not None:
|
||||
parent_subquery = (
|
||||
select(BookRecordLink.from_id)
|
||||
.where(BookRecordLink.relation == "parent", BookRecordLink.to_id == parent)
|
||||
)
|
||||
conditions.append(BookRecord.id.in_(parent_subquery))
|
||||
|
||||
if tag is not None:
|
||||
tag_subquery = select(BookTag.record_id).where(BookTag.tag == tag)
|
||||
conditions.append(BookRecord.id.in_(tag_subquery))
|
||||
|
||||
base_query = select(BookRecord).where(*conditions)
|
||||
|
||||
count_query = select(func.count()).select_from(base_query.subquery())
|
||||
total = (await session.execute(count_query)).scalar() or 0
|
||||
|
||||
query = (
|
||||
base_query
|
||||
.order_by(BookRecord.last_touched_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
records = list((await session.execute(query)).scalars().all())
|
||||
|
||||
results = []
|
||||
for record in records:
|
||||
tags = await self._tags_for(session, record.id)
|
||||
results.append((record, tags))
|
||||
|
||||
return results, total
|
||||
|
||||
async def find_records(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
q: str,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> Tuple[List[Tuple[BookRecord, List[str]]], int]:
|
||||
"""`find` — full-text search over title and body."""
|
||||
tsquery = func.websearch_to_tsquery("simple", q)
|
||||
base_query = select(BookRecord).where(BookRecord.search.op("@@")(tsquery))
|
||||
|
||||
count_query = select(func.count()).select_from(base_query.subquery())
|
||||
total = (await session.execute(count_query)).scalar() or 0
|
||||
|
||||
query = base_query.order_by(BookRecord.updated_at.desc()).offset(offset).limit(limit)
|
||||
records = list((await session.execute(query)).scalars().all())
|
||||
|
||||
results = []
|
||||
for record in records:
|
||||
tags = await self._tags_for(session, record.id)
|
||||
results.append((record, tags))
|
||||
|
||||
return results, total
|
||||
|
||||
# =========================================================================
|
||||
# change / close
|
||||
# =========================================================================
|
||||
|
||||
async def change_record(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
record_id,
|
||||
data: BookRecordUpdate,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str]]:
|
||||
"""`change` — a partial update; only fields set on the request move."""
|
||||
record = await self._get_or_404(session, record_id)
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
diff: dict = {}
|
||||
|
||||
new_kind = update_data.get("kind", record.kind)
|
||||
if "status" in update_data or "kind" in update_data:
|
||||
resolved_status = self._validate_status(new_kind, update_data.get("status"))
|
||||
update_data["status"] = resolved_status
|
||||
|
||||
if "checklist" in update_data:
|
||||
update_data["checklist"] = [
|
||||
item if isinstance(item, dict) else item.model_dump()
|
||||
for item in update_data["checklist"]
|
||||
]
|
||||
|
||||
for field, value in update_data.items():
|
||||
before = getattr(record, field)
|
||||
if before != value:
|
||||
diff[field] = {"from": str(before), "to": str(value)}
|
||||
setattr(record, field, value)
|
||||
|
||||
self._touch(record)
|
||||
|
||||
if diff:
|
||||
await self._write_event(session, record_id, caller, "change", diff)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(record)
|
||||
|
||||
tags = await self._tags_for(session, record_id)
|
||||
logger.info(f"Book record changed: id={record_id} by={caller.identity} fields={list(diff)}")
|
||||
return record, tags
|
||||
|
||||
async def close_record(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
record_id,
|
||||
data: BookCloseRequest,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str]]:
|
||||
"""
|
||||
`close` — a Task is done or dropped; a Question is resolved into a
|
||||
Decision (`resolves` link, status `settled`); a Decision is
|
||||
superseded by a newer one (`supersedes` link, status `superseded`).
|
||||
"""
|
||||
record = await self._get_or_404(session, record_id)
|
||||
diff: dict = {}
|
||||
|
||||
if record.kind == "task":
|
||||
if data.outcome is None:
|
||||
raise BookError("invalid_status", 400)
|
||||
record.status = data.outcome
|
||||
diff = {"status": data.outcome}
|
||||
|
||||
elif record.kind == "question":
|
||||
if data.resolved_by is None:
|
||||
raise BookError("invalid_status", 400)
|
||||
decision = await self._get_or_404(session, data.resolved_by)
|
||||
if decision.kind != "decision":
|
||||
raise BookError("invalid_status", 400)
|
||||
session.add(BookRecordLink(
|
||||
from_id=decision.id,
|
||||
relation="resolves",
|
||||
to_id=record.id,
|
||||
created_by=caller.identity,
|
||||
))
|
||||
record.status = "settled"
|
||||
diff = {"status": "settled", "resolved_by": str(decision.id)}
|
||||
|
||||
elif record.kind == "decision":
|
||||
if data.superseded_by is None:
|
||||
raise BookError("invalid_status", 400)
|
||||
new_decision = await self._get_or_404(session, data.superseded_by)
|
||||
if new_decision.kind != "decision":
|
||||
raise BookError("invalid_status", 400)
|
||||
session.add(BookRecordLink(
|
||||
from_id=new_decision.id,
|
||||
relation="supersedes",
|
||||
to_id=record.id,
|
||||
created_by=caller.identity,
|
||||
))
|
||||
record.status = "superseded"
|
||||
diff = {"status": "superseded", "superseded_by": str(new_decision.id)}
|
||||
|
||||
else:
|
||||
# Notes and Rejected options have no `close` transition.
|
||||
raise BookError("invalid_status", 400)
|
||||
|
||||
self._touch(record)
|
||||
await self._write_event(session, record_id, caller, "close", diff)
|
||||
await session.commit()
|
||||
await session.refresh(record)
|
||||
|
||||
tags = await self._tags_for(session, record_id)
|
||||
logger.info(f"Book record closed: id={record_id} by={caller.identity} diff={diff}")
|
||||
return record, tags
|
||||
|
||||
# =========================================================================
|
||||
# link / unlink
|
||||
# =========================================================================
|
||||
|
||||
async def link_records(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
record_id,
|
||||
to_id,
|
||||
relation: str,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str]]:
|
||||
record = await self._get_or_404(session, record_id)
|
||||
await self._get_or_404(session, to_id) # the target must exist
|
||||
|
||||
session.add(BookRecordLink(
|
||||
from_id=record_id,
|
||||
relation=relation,
|
||||
to_id=to_id,
|
||||
created_by=caller.identity,
|
||||
))
|
||||
await self._write_event(
|
||||
session, record_id, caller, "link", {"relation": relation, "to_id": str(to_id)}
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(record)
|
||||
|
||||
tags = await self._tags_for(session, record_id)
|
||||
return record, tags
|
||||
|
||||
async def unlink_records(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
record_id,
|
||||
to_id,
|
||||
relation: str,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str]]:
|
||||
record = await self._get_or_404(session, record_id)
|
||||
|
||||
stmt = delete(BookRecordLink).where(
|
||||
BookRecordLink.from_id == record_id,
|
||||
BookRecordLink.relation == relation,
|
||||
BookRecordLink.to_id == to_id,
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
if result.rowcount == 0:
|
||||
raise BookError("not_found", 404)
|
||||
|
||||
await self._write_event(
|
||||
session, record_id, caller, "unlink", {"relation": relation, "to_id": str(to_id)}
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(record)
|
||||
|
||||
tags = await self._tags_for(session, record_id)
|
||||
return record, tags
|
||||
|
||||
# =========================================================================
|
||||
# tag / untag
|
||||
# =========================================================================
|
||||
|
||||
async def tag_record(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
record_id,
|
||||
tag: str,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str]]:
|
||||
record = await self._get_or_404(session, record_id)
|
||||
|
||||
existing = await session.get(BookTag, (record_id, tag))
|
||||
if existing is None:
|
||||
session.add(BookTag(record_id=record_id, tag=tag))
|
||||
await self._write_event(session, record_id, caller, "tag", {"tag": tag})
|
||||
await session.commit()
|
||||
|
||||
tags = await self._tags_for(session, record_id)
|
||||
return record, tags
|
||||
|
||||
async def untag_record(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
record_id,
|
||||
tag: str,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str]]:
|
||||
record = await self._get_or_404(session, record_id)
|
||||
|
||||
stmt = delete(BookTag).where(BookTag.record_id == record_id, BookTag.tag == tag)
|
||||
result = await session.execute(stmt)
|
||||
if result.rowcount > 0:
|
||||
await self._write_event(session, record_id, caller, "untag", {"tag": tag})
|
||||
await session.commit()
|
||||
|
||||
tags = await self._tags_for(session, record_id)
|
||||
return record, tags
|
||||
|
||||
# =========================================================================
|
||||
# history
|
||||
# =========================================================================
|
||||
|
||||
async def get_history(self, session: AsyncSession, record_id, limit: int = 100) -> List[BookEvent]:
|
||||
await self._get_or_404(session, record_id)
|
||||
|
||||
stmt = (
|
||||
select(BookEvent)
|
||||
.where(BookEvent.record_id == record_id)
|
||||
.order_by(BookEvent.at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_book_service: Optional[BookService] = None
|
||||
|
||||
|
||||
def get_book_service() -> BookService:
|
||||
"""Get singleton Book service instance"""
|
||||
global _book_service
|
||||
if _book_service is None:
|
||||
_book_service = BookService()
|
||||
return _book_service
|
||||
@@ -19,6 +19,7 @@ from src.domains.infrastructure import infrastructure_controller
|
||||
from src.domains.housekeeping import housekeeping_controller
|
||||
from src.domains.static import static_controller
|
||||
from src.domains.dashboard import dashboard_controller
|
||||
from src.domains.book import book_controller
|
||||
|
||||
# Initialize settings
|
||||
settings = get_settings()
|
||||
@@ -106,6 +107,7 @@ app.include_router(infrastructure_controller.router) # /infrastructure
|
||||
app.include_router(housekeeping_controller.router) # /housekeeping/*
|
||||
app.include_router(static_controller.router) # /static/*
|
||||
app.include_router(dashboard_controller.router) # /dashboard/*
|
||||
app.include_router(book_controller.router) # /book/*
|
||||
|
||||
|
||||
# Global exception handler
|
||||
|
||||
Reference in New Issue
Block a user