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>
328 lines
13 KiB
Python
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()
|