from typing import Annotated from fastapi import APIRouter, Depends, File, Form, Path, Query, Response, UploadFile from fastapi.responses import JSONResponse from src.api.dependencies import DbSessionDep, GuestSessionHeaderDep from src.api.errors import error_response from src.config import Settings, get_settings from src.models.auth import AuthorityLevel, SourceDocumentStatus, SourceScope from src.models.schemas import ( DocumentListResponse, DocumentType, DocumentUploadResult, EffectiveStatus, IngestionStatus, Purpose, RetentionPolicy, ScopedDocumentItem, ScopedDocumentListResponse, ScopedDocumentUploadResult, ) from src.services.documents import ( DocumentLibraryService, DocumentListFilters, DocumentServiceError, DocumentUploadInput, InMemoryDocumentIngestionService, ScopedUploadContext, build_document_metadata, document_ingestion_service, parse_upload_metadata, ) router = APIRouter(prefix="/documents", tags=["documents"]) SettingsDep = Annotated[Settings, Depends(get_settings)] def get_document_service() -> InMemoryDocumentIngestionService: return document_ingestion_service @router.post("", response_model=DocumentUploadResult) async def upload_document( file: Annotated[UploadFile, File()], settings: SettingsDep, service: Annotated[InMemoryDocumentIngestionService, Depends(get_document_service)], document_type: Annotated[DocumentType, Form()], purpose: Annotated[Purpose, Form()], retention_policy: Annotated[RetentionPolicy, Form()], title: Annotated[str | None, Form(max_length=500)] = None, metadata: Annotated[str | None, Form()] = None, ) -> DocumentUploadResult | JSONResponse: """Upload a document and queue it for ingestion.""" try: raw_metadata = parse_upload_metadata(metadata) document_metadata = build_document_metadata(raw_metadata, title) record = service.upload_document( DocumentUploadInput( filename=file.filename or "", content=await file.read(), document_type=document_type, purpose=purpose, retention_policy=retention_policy, title=title, metadata=document_metadata, ), max_upload_bytes=settings.max_upload_bytes, ) except DocumentServiceError as exc: return error_response( status_code=exc.status_code, code=exc.code, message=exc.message, details=exc.details, ) return DocumentUploadResult( document_id=record.document_id, filename=record.filename, document_type=record.document_type, purpose=record.purpose, retention_policy=record.retention_policy, ingestion_status=record.ingestion_status, metadata=record.metadata, created_at=record.created_at, ) @router.get("", response_model=DocumentListResponse) def list_documents( service: Annotated[InMemoryDocumentIngestionService, Depends(get_document_service)], page: Annotated[int, Query(ge=1)] = 1, page_size: Annotated[int, Query(ge=1, le=100)] = 20, document_type: Annotated[DocumentType | None, Query()] = None, purpose: Annotated[Purpose | None, Query()] = None, ingestion_status: Annotated[IngestionStatus | None, Query()] = None, effective_status: Annotated[EffectiveStatus | None, Query()] = None, q: Annotated[str | None, Query(max_length=200)] = None, ) -> DocumentListResponse | JSONResponse: """List uploaded or indexed documents.""" try: items, total = service.list_documents( DocumentListFilters( page=page, page_size=page_size, document_type=document_type, purpose=purpose, ingestion_status=ingestion_status, effective_status=effective_status, q=q, ) ) except DocumentServiceError as exc: return error_response( status_code=exc.status_code, code=exc.code, message=exc.message, details=exc.details, ) return DocumentListResponse( items=items, page=page, page_size=page_size, total=total, ) @router.post("/guest/upload", response_model=ScopedDocumentUploadResult) async def upload_guest_document( file: Annotated[UploadFile, File()], response: Response, settings: SettingsDep, db: DbSessionDep, guest_session_id: GuestSessionHeaderDep = None, document_type: Annotated[DocumentType, Form()] = DocumentType.OTHER, purpose: Annotated[Purpose, Form()] = Purpose.DRAFT_CONTEXT, title: Annotated[str | None, Form(max_length=500)] = None, metadata: Annotated[str | None, Form()] = None, ) -> ScopedDocumentUploadResult | JSONResponse: service = DocumentLibraryService(db, settings) session_id = service.ensure_guest_session_id(guest_session_id) response.headers["X-Guest-Session-Id"] = session_id response.set_cookie( settings.guest_session_cookie_name, session_id, max_age=settings.guest_upload_ttl_hours * 3600, httponly=True, secure=settings.auth_cookie_secure, samesite="lax", ) return await _upload_scoped( file=file, service=service, document_type=document_type, purpose=purpose, retention_policy=RetentionPolicy.SESSION, title=title, metadata=metadata, context=ScopedUploadContext( source_scope=SourceScope.GUEST_SESSION, session_id=session_id, authority_level=AuthorityLevel.UPLOADED_CONTEXT, document_status=_document_status_from_metadata(metadata), ), ) @router.get("/guest", response_model=ScopedDocumentListResponse) def list_guest_documents( db: DbSessionDep, settings: SettingsDep, guest_session_id: GuestSessionHeaderDep = None, ) -> ScopedDocumentListResponse: service = DocumentLibraryService(db, settings) session_id = service.ensure_guest_session_id(guest_session_id) items = service.list_documents( ScopedUploadContext(source_scope=SourceScope.GUEST_SESSION, session_id=session_id) ) return ScopedDocumentListResponse( items=[service.to_item(item) for item in items], total=len(items), ) @router.delete("/guest/{document_id}", response_model=ScopedDocumentItem) def delete_guest_document( document_id: Annotated[str, Path()], db: DbSessionDep, settings: SettingsDep, guest_session_id: GuestSessionHeaderDep = None, ) -> ScopedDocumentItem | JSONResponse: try: service = DocumentLibraryService(db, settings) record = service.delete_document( document_id, ScopedUploadContext( source_scope=SourceScope.GUEST_SESSION, session_id=service.ensure_guest_session_id(guest_session_id), ), ) except DocumentServiceError as exc: return _document_error(exc) return service.to_item(record) async def _upload_scoped( *, file: UploadFile, service: DocumentLibraryService, document_type: DocumentType, purpose: Purpose, retention_policy: RetentionPolicy, title: str | None, metadata: str | None, context: ScopedUploadContext, ) -> ScopedDocumentUploadResult | JSONResponse: try: raw_metadata = parse_upload_metadata(metadata) document_metadata = build_document_metadata(raw_metadata, title) record, chunk_count, warnings = service.upload_scoped_document( DocumentUploadInput( filename=file.filename or "", content=await file.read(), document_type=document_type, purpose=purpose, retention_policy=retention_policy, title=title, metadata=document_metadata, ), context=context, mime_type=file.content_type or "", ) except DocumentServiceError as exc: return _document_error(exc) return ScopedDocumentUploadResult( document=service.to_item(record), chunk_count=chunk_count, warnings=warnings, ) def _document_status_from_metadata(metadata: str | None) -> SourceDocumentStatus: try: raw = parse_upload_metadata(metadata) except DocumentServiceError: return SourceDocumentStatus.UNKNOWN value = str(raw.get("document_status") or raw.get("effective_status") or "active") if value in {"repealed", "expired", "amended"}: return SourceDocumentStatus.OUTDATED try: return SourceDocumentStatus(value) except ValueError: return SourceDocumentStatus.UNKNOWN def _document_error(exc: DocumentServiceError) -> JSONResponse: return error_response( status_code=exc.status_code, code=exc.code, message=exc.message, details=exc.details, )