""" 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()