Files
core-api/src/domains/book/controller.py
T
jpmschweitzerandClaude Sonnet 5 e02173aefb feat(book): tenant boundary and per-tenant numbering on the Book (T-7)
005 has never run in production (still at 004), so it is amended in place
rather than patched later by a 007: book_records gains owner_tenant (the
tatlock tenant, Caller.tenant from T-4) and number, a short outward id
allocated per tenant via a new book_tenant_counters row locked FOR UPDATE
in the same transaction as the insert — INSERT ... ON CONFLICT DO NOTHING
bootstraps the counter row so a tenant's very first allocation doesn't
race its own creation. Unique on (owner_tenant, number); composite index
on (owner_tenant, kind, status). 006 applies on top unchanged.

Every Book route now requires a tenant (BookService._require_tenant,
mirroring the Scribe's own pattern) and filters on it: a record in
another tenant answers exactly like one that does not exist, in both
directions. link and add's parent convenience both route through
_get_or_404, so a link can never reach another tenant — which is also
what keeps show's links/backlinks free of their own tenant filter, since
every link touching a tenant's record is created tenant-safe already.
Responses include number; list accepts number= to find one.

Verified: core_api_test downgraded to 004 (with the original migration,
to match its already-applied pre-amendment schema), amended, upgraded
back to head — clean round trip. 508 tests pass (489 baseline + 19 new:
3 migration-shape, 16 tenant/numbering). Mutation checks confirmed and
reverted: dropping list's tenant filter, allocating numbers from a global
counter, and dropping link's tenant check on its target each fail the
test built to catch it. Raw REST against a dev server on :8777 against
core_api_test confirms the same behaviour over the wire: two tenants'
first records both land on number 1, and a cross-tenant read answers 404
not_found.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-19 16:28:08 +02:00

328 lines
13 KiB
Python

"""
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). Every route also
requires a tenant on the caller — enforced in the service layer
(`BookService._require_tenant`) rather than here, so reads and writes share
one check (core-api T-7).
"""
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),
number: Optional[int] = Query(None, description="find the one record with this outward id"),
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),
):
try:
results, total = await service.list_records(
session, caller, view=view, tag=tag, scope=scope, parent=parent,
number=number, limit=limit, offset=offset,
)
except BookError as error:
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
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),
):
try:
results, total = await service.find_records(session, caller, q=q, limit=limit, offset=offset)
except BookError as error:
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
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, caller)
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, caller)
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()