From 4b195e458e1797be04434abe45c2edf4d5654f62 Mon Sep 17 00:00:00 2001 From: maltonoloco Date: Wed, 26 Aug 2026 18:38:57 +0200 Subject: [PATCH 1/2] feat(accounting): add Paperless-backed admin API --- src/app/main.py | 4 + src/app/services/accounting/__init__.py | 10 + .../services/accounting/adapters/__init__.py | 4 + .../services/accounting/adapters/interface.py | 36 +++ .../accounting/adapters/paperless_adapter.py | 90 +++++++ src/app/services/accounting/dependencies.py | 22 ++ .../services/accounting/functions/__init__.py | 3 + .../functions/accounting_operations.py | 117 +++++++++ .../services/accounting/models/__init__.py | 21 ++ .../accounting/models/accounting_models.py | 87 +++++++ .../services/accounting/routers/__init__.py | 1 + .../accounting/routers/accounting_router.py | 228 ++++++++++++++++++ src/app/shared/config/settings.py | 15 ++ tests/test_accounting_unit.py | 89 +++++++ 14 files changed, 727 insertions(+) create mode 100644 src/app/services/accounting/__init__.py create mode 100644 src/app/services/accounting/adapters/__init__.py create mode 100644 src/app/services/accounting/adapters/interface.py create mode 100644 src/app/services/accounting/adapters/paperless_adapter.py create mode 100644 src/app/services/accounting/dependencies.py create mode 100644 src/app/services/accounting/functions/__init__.py create mode 100644 src/app/services/accounting/functions/accounting_operations.py create mode 100644 src/app/services/accounting/models/__init__.py create mode 100644 src/app/services/accounting/models/accounting_models.py create mode 100644 src/app/services/accounting/routers/__init__.py create mode 100644 src/app/services/accounting/routers/accounting_router.py create mode 100644 tests/test_accounting_unit.py diff --git a/src/app/main.py b/src/app/main.py index ab0f014..31d32c1 100644 --- a/src/app/main.py +++ b/src/app/main.py @@ -7,6 +7,7 @@ from slowapi.middleware import SlowAPIMiddleware from app.chore import lifespan +from app.services.accounting import accounting_api_router from app.services.admin import admin_api_router from app.services.analytics import analytics_api_router from app.services.crud_item_store import router as item_store_router @@ -183,6 +184,9 @@ async def generic_exception_handler(request: Request, exc: Exception) -> JSONRes # Provider-neutral mailbox endpoints for the admin webmail client app.include_router(mail_api_router, prefix="/v1") +# Provider-neutral accounting document endpoints for the admin frontend +app.include_router(accounting_api_router, prefix="/v1") + # Include health check endpoints (Phase 4.1) app.include_router(health_api_router) diff --git a/src/app/services/accounting/__init__.py b/src/app/services/accounting/__init__.py new file mode 100644 index 0000000..b1408d2 --- /dev/null +++ b/src/app/services/accounting/__init__.py @@ -0,0 +1,10 @@ +"""Provider-neutral admin accounting document API.""" + +from fastapi import APIRouter + +from .routers.accounting_router import router + +accounting_api_router = APIRouter(prefix="/admin/accounting", tags=["Admin Accounting"]) +accounting_api_router.include_router(router) + +__all__ = ["accounting_api_router"] diff --git a/src/app/services/accounting/adapters/__init__.py b/src/app/services/accounting/adapters/__init__.py new file mode 100644 index 0000000..9772ad3 --- /dev/null +++ b/src/app/services/accounting/adapters/__init__.py @@ -0,0 +1,4 @@ +from .interface import AccountingAdapter, Download +from .paperless_adapter import PaperlessAccountingAdapter + +__all__ = ["AccountingAdapter", "Download", "PaperlessAccountingAdapter"] diff --git a/src/app/services/accounting/adapters/interface.py b/src/app/services/accounting/adapters/interface.py new file mode 100644 index 0000000..6a19328 --- /dev/null +++ b/src/app/services/accounting/adapters/interface.py @@ -0,0 +1,36 @@ +"""Provider boundary for accounting document management.""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class Download: + content: bytes + content_type: str + filename: str | None + + +class AccountingAdapter(ABC): + @abstractmethod + async def get(self, path: str, params: dict[str, Any] | None = None) -> Any: ... + + @abstractmethod + async def post( + self, + path: str, + *, + json: dict[str, Any] | None = None, + data: dict[str, Any] | None = None, + files: dict[str, tuple[str, bytes, str]] | None = None, + ) -> Any: ... + + @abstractmethod + async def patch(self, path: str, json: dict[str, Any]) -> Any: ... + + @abstractmethod + async def delete(self, path: str) -> None: ... + + @abstractmethod + async def download(self, path: str) -> Download: ... diff --git a/src/app/services/accounting/adapters/paperless_adapter.py b/src/app/services/accounting/adapters/paperless_adapter.py new file mode 100644 index 0000000..4c9d843 --- /dev/null +++ b/src/app/services/accounting/adapters/paperless_adapter.py @@ -0,0 +1,90 @@ +"""Async Paperless-ngx REST adapter.""" + +from typing import Any + +import httpx + +from app.shared.config.settings import Settings +from app.shared.exceptions import ExternalServiceError, NotFoundError, ValidationError + +from .interface import AccountingAdapter, Download + + +class PaperlessAccountingAdapter(AccountingAdapter): + def __init__(self, settings: Settings) -> None: + self._base_url = settings.paperless_url.rstrip("/") + self._token = settings.paperless_token + self._timeout = settings.paperless_timeout_seconds + + def _client(self) -> httpx.AsyncClient: + return httpx.AsyncClient( + base_url=self._base_url, + headers={"Authorization": f"Token {self._token}"}, + timeout=self._timeout, + ) + + async def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: + if not self._base_url or not self._token: + raise ExternalServiceError( + "Accounting service is not configured", + context={"provider": "paperless_ngx"}, + ) + try: + async with self._client() as client: + response = await client.request( + method, f"/api/{path.lstrip('/')}", **kwargs + ) + except httpx.HTTPError as exc: + raise ExternalServiceError( + "Paperless-ngx is unavailable", + context={"provider": "paperless_ngx"}, + original_exception=exc, + ) from exc + if response.status_code == 404: + raise NotFoundError("Accounting resource not found") + if response.status_code in {400, 409, 422}: + raise ValidationError( + "Paperless-ngx rejected the request", + context={"provider_status": response.status_code}, + ) + if response.is_error: + raise ExternalServiceError( + "Paperless-ngx request failed", + context={"provider_status": response.status_code}, + ) + return response + + async def get(self, path: str, params: dict[str, Any] | None = None) -> Any: + return (await self._request("GET", path, params=params)).json() + + async def post( + self, + path: str, + *, + json: dict[str, Any] | None = None, + data: dict[str, Any] | None = None, + files: dict[str, tuple[str, bytes, str]] | None = None, + ) -> Any: + return ( + await self._request("POST", path, json=json, data=data, files=files) + ).json() + + async def patch(self, path: str, json: dict[str, Any]) -> Any: + return (await self._request("PATCH", path, json=json)).json() + + async def delete(self, path: str) -> None: + await self._request("DELETE", path) + + async def download(self, path: str) -> Download: + response = await self._request("GET", path) + disposition = response.headers.get("content-disposition", "") + filename = None + if "filename=" in disposition: + filename = disposition.split("filename=", 1)[1].strip('" ') + return Download( + content=response.content, + content_type=response.headers.get( + "content-type", "application/octet-stream" + ), + filename=filename, + ) diff --git a/src/app/services/accounting/dependencies.py b/src/app/services/accounting/dependencies.py new file mode 100644 index 0000000..fe2e985 --- /dev/null +++ b/src/app/services/accounting/dependencies.py @@ -0,0 +1,22 @@ +"""Composition root for accounting dependencies.""" + +from typing import Annotated + +from fastapi import Depends + +from app.shared.config import get_settings + +from .adapters import PaperlessAccountingAdapter +from .functions import AccountingOperations + + +def get_accounting_operations() -> AccountingOperations: + settings = get_settings() + return AccountingOperations(PaperlessAccountingAdapter(settings), settings) + + +AccountingOperationsDependency = Annotated[ + AccountingOperations, Depends(get_accounting_operations) +] + +__all__ = ["AccountingOperationsDependency", "get_accounting_operations"] diff --git a/src/app/services/accounting/functions/__init__.py b/src/app/services/accounting/functions/__init__.py new file mode 100644 index 0000000..1fb6840 --- /dev/null +++ b/src/app/services/accounting/functions/__init__.py @@ -0,0 +1,3 @@ +from .accounting_operations import AccountingOperations + +__all__ = ["AccountingOperations"] diff --git a/src/app/services/accounting/functions/accounting_operations.py b/src/app/services/accounting/functions/accounting_operations.py new file mode 100644 index 0000000..547b8cc --- /dev/null +++ b/src/app/services/accounting/functions/accounting_operations.py @@ -0,0 +1,117 @@ +"""Provider-neutral accounting document use cases.""" + +from typing import Any + +from app.shared.config.settings import Settings +from app.shared.exceptions import ValidationError + +from ..adapters import AccountingAdapter, Download +from ..models import AccountingStatus, BulkEditRequest, DocumentUpdate, ResourceWrite + +RESOURCE_PATHS = { + "tags": "tags", + "correspondents": "correspondents", + "document-types": "document_types", + "storage-paths": "storage_paths", + "custom-fields": "custom_fields", +} + + +class AccountingOperations: + def __init__(self, adapter: AccountingAdapter, settings: Settings) -> None: + self._adapter = adapter + self._settings = settings + + @property + def max_upload_bytes(self) -> int: + """Maximum bytes the HTTP layer should read from one upload.""" + return self._settings.paperless_max_upload_bytes + + async def status(self, probe: bool = False) -> AccountingStatus: + configured = bool( + self._settings.paperless_url and self._settings.paperless_token + ) + if not probe or not configured: + return AccountingStatus(configured=configured) + result = await self._adapter.get("status/") + return AccountingStatus( + configured=True, + reachable=True, + version=result.get("version") if isinstance(result, dict) else None, + ) + + async def list_documents(self, params: dict[str, Any]) -> dict[str, Any]: + return await self._adapter.get("documents/", params) + + async def get_document(self, document_id: int) -> dict[str, Any]: + return await self._adapter.get(f"documents/{document_id}/") + + async def update_document( + self, document_id: int, payload: DocumentUpdate + ) -> dict[str, Any]: + return await self._adapter.patch( + f"documents/{document_id}/", + payload.model_dump(exclude_unset=True, mode="json"), + ) + + async def delete_document(self, document_id: int) -> None: + await self._adapter.delete(f"documents/{document_id}/") + + async def upload( + self, filename: str, content_type: str, content: bytes, fields: dict[str, Any] + ) -> str: + if len(content) > self._settings.paperless_max_upload_bytes: + raise ValidationError( + "Document exceeds the configured upload limit", + context={"max_bytes": self._settings.paperless_max_upload_bytes}, + ) + result = await self._adapter.post( + "documents/post_document/", + data={key: value for key, value in fields.items() if value is not None}, + files={"document": (filename, content, content_type)}, + ) + return str(result) + + async def download(self, document_id: int, variant: str) -> Download: + endpoint = {"original": "download", "preview": "preview", "thumbnail": "thumb"}[ + variant + ] + return await self._adapter.download(f"documents/{document_id}/{endpoint}/") + + async def bulk_edit(self, payload: BulkEditRequest) -> Any: + return await self._adapter.post( + "documents/bulk_edit/", json=payload.model_dump(mode="json") + ) + + async def list_tasks(self, params: dict[str, Any]) -> dict[str, Any]: + return await self._adapter.get("tasks/", params) + + def _resource_path(self, resource: str) -> str: + try: + return RESOURCE_PATHS[resource] + except KeyError as exc: + raise ValidationError("Unsupported accounting resource") from exc + + async def list_resources( + self, resource: str, params: dict[str, Any] + ) -> dict[str, Any]: + return await self._adapter.get(f"{self._resource_path(resource)}/", params) + + async def create_resource( + self, resource: str, payload: ResourceWrite + ) -> dict[str, Any]: + return await self._adapter.post( + f"{self._resource_path(resource)}/", + json=payload.model_dump(exclude_unset=True), + ) + + async def update_resource( + self, resource: str, resource_id: int, payload: ResourceWrite + ) -> dict[str, Any]: + return await self._adapter.patch( + f"{self._resource_path(resource)}/{resource_id}/", + payload.model_dump(exclude_unset=True), + ) + + async def delete_resource(self, resource: str, resource_id: int) -> None: + await self._adapter.delete(f"{self._resource_path(resource)}/{resource_id}/") diff --git a/src/app/services/accounting/models/__init__.py b/src/app/services/accounting/models/__init__.py new file mode 100644 index 0000000..a586f58 --- /dev/null +++ b/src/app/services/accounting/models/__init__.py @@ -0,0 +1,21 @@ +from .accounting_models import ( + AccountingStatus, + BulkEditRequest, + DocumentUpdate, + PaperlessDocument, + PaperlessPage, + PaperlessResource, + ResourceWrite, + UploadResult, +) + +__all__ = [ + "AccountingStatus", + "BulkEditRequest", + "DocumentUpdate", + "PaperlessDocument", + "PaperlessPage", + "PaperlessResource", + "ResourceWrite", + "UploadResult", +] diff --git a/src/app/services/accounting/models/accounting_models.py b/src/app/services/accounting/models/accounting_models.py new file mode 100644 index 0000000..cd8345b --- /dev/null +++ b/src/app/services/accounting/models/accounting_models.py @@ -0,0 +1,87 @@ +"""Stable admin-facing schemas for accounting documents and classifications.""" + +from datetime import date, datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class PaperlessResource(BaseModel): + """A Paperless classification object, retaining provider extension fields.""" + + model_config = ConfigDict(extra="allow") + + id: int + name: str + + +class PaperlessDocument(BaseModel): + """Document fields used by the admin UI, with forward-compatible extras.""" + + model_config = ConfigDict(extra="allow") + + id: int + title: str + content: str | None = None + created: date | None = None + added: datetime | None = None + modified: datetime | None = None + correspondent: int | None = None + document_type: int | None = None + storage_path: int | None = None + tags: list[int] = Field(default_factory=list) + archive_serial_number: int | None = None + original_file_name: str | None = None + archived_file_name: str | None = None + + +class PaperlessPage(BaseModel): + """Paperless-compatible cursor/page response.""" + + model_config = ConfigDict(extra="allow") + + count: int + next: str | None = None + previous: str | None = None + results: list[dict[str, Any]] + + +class AccountingStatus(BaseModel): + configured: bool + provider: str = "paperless_ngx" + reachable: bool | None = None + version: str | None = None + + +class DocumentUpdate(BaseModel): + """Editable root-document metadata.""" + + model_config = ConfigDict(extra="forbid") + + title: str | None = Field(default=None, min_length=1, max_length=512) + content: str | None = None + created: date | None = None + correspondent: int | None = Field(default=None, ge=1) + document_type: int | None = Field(default=None, ge=1) + storage_path: int | None = Field(default=None, ge=1) + tags: list[int] | None = None + archive_serial_number: int | None = Field(default=None, ge=1) + custom_fields: list[dict[str, Any]] | None = None + + +class ResourceWrite(BaseModel): + """Create/update payload common to Paperless classifiers.""" + + model_config = ConfigDict(extra="allow") + + name: str = Field(min_length=1, max_length=256) + + +class BulkEditRequest(BaseModel): + documents: list[int] = Field(min_length=1) + method: str = Field(min_length=1, max_length=64) + parameters: dict[str, Any] = Field(default_factory=dict) + + +class UploadResult(BaseModel): + task_id: str diff --git a/src/app/services/accounting/routers/__init__.py b/src/app/services/accounting/routers/__init__.py new file mode 100644 index 0000000..67339e1 --- /dev/null +++ b/src/app/services/accounting/routers/__init__.py @@ -0,0 +1 @@ +"""Accounting API routers.""" diff --git a/src/app/services/accounting/routers/accounting_router.py b/src/app/services/accounting/routers/accounting_router.py new file mode 100644 index 0000000..315532d --- /dev/null +++ b/src/app/services/accounting/routers/accounting_router.py @@ -0,0 +1,228 @@ +"""Admin-only REST facade for Paperless-backed accounting documents.""" + +from typing import Any, Literal +from urllib.parse import quote + +from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile, status + +from app.services.admin.dependencies import require_admin +from app.shared.responses import DataResponse + +from ..dependencies import AccountingOperationsDependency +from ..models import ( + AccountingStatus, + BulkEditRequest, + DocumentUpdate, + PaperlessDocument, + PaperlessPage, + PaperlessResource, + ResourceWrite, + UploadResult, +) + +router = APIRouter(dependencies=[Depends(require_admin)]) +ResourceName = Literal[ + "tags", "correspondents", "document-types", "storage-paths", "custom-fields" +] + + +@router.get("/status", response_model=DataResponse[AccountingStatus]) +async def accounting_status( + operations: AccountingOperationsDependency, probe: bool = False +) -> DataResponse[AccountingStatus]: + return DataResponse( + data=await operations.status(probe), message="Accounting status retrieved" + ) + + +@router.get("/documents", response_model=DataResponse[PaperlessPage]) +async def list_documents( + operations: AccountingOperationsDependency, + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), + query: str | None = Query(None, max_length=500), + correspondent: int | None = Query(None, ge=1), + document_type: int | None = Query(None, ge=1), + storage_path: int | None = Query(None, ge=1), + tags: list[int] | None = Query(None), + ordering: str | None = Query(None, max_length=100), +) -> DataResponse[PaperlessPage]: + params = { + "page": page, + "page_size": page_size, + "text": query, + "correspondent__id": correspondent, + "document_type__id": document_type, + "storage_path__id": storage_path, + "tags__id__all": ",".join(map(str, tags)) if tags else None, + "ordering": ordering, + } + data = await operations.list_documents( + {k: v for k, v in params.items() if v is not None} + ) + return DataResponse( + data=PaperlessPage.model_validate(data), message="Documents retrieved" + ) + + +@router.post("/documents", response_model=DataResponse[UploadResult], status_code=202) +async def upload_document( + operations: AccountingOperationsDependency, + document: UploadFile = File(...), + title: str | None = Form(None), + created: str | None = Form(None), + correspondent: int | None = Form(None), + document_type: int | None = Form(None), + storage_path: int | None = Form(None), + tags: list[int] | None = Form(None), + archive_serial_number: int | None = Form(None), +) -> DataResponse[UploadResult]: + # Read one byte beyond the limit so oversized files are rejected without + # loading an arbitrarily large request into application memory. + content = await document.read(operations.max_upload_bytes + 1) + task_id = await operations.upload( + document.filename or "document", + document.content_type or "application/octet-stream", + content, + { + "title": title, + "created": created, + "correspondent": correspondent, + "document_type": document_type, + "storage_path": storage_path, + "tags": tags, + "archive_serial_number": archive_serial_number, + }, + ) + return DataResponse(data=UploadResult(task_id=task_id), message="Document queued") + + +@router.post("/documents/bulk-edit", response_model=DataResponse[Any], status_code=202) +async def bulk_edit_documents( + payload: BulkEditRequest, operations: AccountingOperationsDependency +) -> DataResponse[Any]: + return DataResponse( + data=await operations.bulk_edit(payload), message="Bulk edit queued" + ) + + +@router.get("/documents/{document_id}", response_model=DataResponse[PaperlessDocument]) +async def get_document( + document_id: int, operations: AccountingOperationsDependency +) -> DataResponse[PaperlessDocument]: + data = await operations.get_document(document_id) + return DataResponse( + data=PaperlessDocument.model_validate(data), message="Document retrieved" + ) + + +@router.patch( + "/documents/{document_id}", response_model=DataResponse[PaperlessDocument] +) +async def update_document( + document_id: int, + payload: DocumentUpdate, + operations: AccountingOperationsDependency, +) -> DataResponse[PaperlessDocument]: + data = await operations.update_document(document_id, payload) + return DataResponse( + data=PaperlessDocument.model_validate(data), message="Document updated" + ) + + +@router.delete("/documents/{document_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_document( + document_id: int, operations: AccountingOperationsDependency +) -> None: + await operations.delete_document(document_id) + + +@router.get("/documents/{document_id}/file") +async def download_document( + document_id: int, + operations: AccountingOperationsDependency, + variant: Literal["original", "preview", "thumbnail"] = "original", +) -> Response: + downloaded = await operations.download(document_id, variant) + headers = {} + if downloaded.filename: + headers["Content-Disposition"] = ( + f"attachment; filename*=UTF-8''{quote(downloaded.filename)}" + ) + return Response( + downloaded.content, media_type=downloaded.content_type, headers=headers + ) + + +@router.get("/tasks", response_model=DataResponse[PaperlessPage]) +async def list_tasks( + operations: AccountingOperationsDependency, + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), + task_id: str | None = Query(None, max_length=100), +) -> DataResponse[PaperlessPage]: + params = {"page": page, "page_size": page_size, "task_id": task_id} + data = await operations.list_tasks( + {k: v for k, v in params.items() if v is not None} + ) + return DataResponse( + data=PaperlessPage.model_validate(data), message="Tasks retrieved" + ) + + +@router.get("/resources/{resource}", response_model=DataResponse[PaperlessPage]) +async def list_resources( + resource: ResourceName, + operations: AccountingOperationsDependency, + page: int = Query(1, ge=1), + page_size: int = Query(100, ge=1, le=200), + query: str | None = Query(None, max_length=256), +) -> DataResponse[PaperlessPage]: + params = {"page": page, "page_size": page_size, "name__icontains": query} + data = await operations.list_resources( + resource, {k: v for k, v in params.items() if v is not None} + ) + return DataResponse( + data=PaperlessPage.model_validate(data), message="Resources retrieved" + ) + + +@router.post( + "/resources/{resource}", + response_model=DataResponse[PaperlessResource], + status_code=201, +) +async def create_resource( + resource: ResourceName, + payload: ResourceWrite, + operations: AccountingOperationsDependency, +) -> DataResponse[PaperlessResource]: + data = await operations.create_resource(resource, payload) + return DataResponse( + data=PaperlessResource.model_validate(data), message="Resource created" + ) + + +@router.patch( + "/resources/{resource}/{resource_id}", + response_model=DataResponse[PaperlessResource], +) +async def update_resource( + resource: ResourceName, + resource_id: int, + payload: ResourceWrite, + operations: AccountingOperationsDependency, +) -> DataResponse[PaperlessResource]: + data = await operations.update_resource(resource, resource_id, payload) + return DataResponse( + data=PaperlessResource.model_validate(data), message="Resource updated" + ) + + +@router.delete( + "/resources/{resource}/{resource_id}", status_code=status.HTTP_204_NO_CONTENT +) +async def delete_resource( + resource: ResourceName, resource_id: int, operations: AccountingOperationsDependency +) -> None: + await operations.delete_resource(resource, resource_id) diff --git a/src/app/shared/config/settings.py b/src/app/shared/config/settings.py index 76f2202..5b8fdbf 100644 --- a/src/app/shared/config/settings.py +++ b/src/app/shared/config/settings.py @@ -224,6 +224,21 @@ class Settings(BaseSettings): description="Mailbox folders that API clients cannot rename or delete", ) + # Accounting document management (Paperless-ngx) + paperless_url: str = Field( + default="", + description="Paperless-ngx base URL; leave empty to disable accounting", + ) + paperless_token: str = Field(default="", description="Paperless-ngx API token") + paperless_timeout_seconds: int = Field( + default=30, ge=1, description="Paperless-ngx request timeout" + ) + paperless_max_upload_bytes: int = Field( + default=50 * 1024 * 1024, + ge=1, + description="Largest accounting document accepted by the API", + ) + # Feature Flags feature_webhooks_enabled: bool = Field(default=False, description="Enable webhooks") diff --git a/tests/test_accounting_unit.py b/tests/test_accounting_unit.py new file mode 100644 index 0000000..e17cdee --- /dev/null +++ b/tests/test_accounting_unit.py @@ -0,0 +1,89 @@ +"""Unit tests for the provider-neutral accounting service.""" + +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.services.accounting import accounting_api_router +from app.services.accounting.dependencies import get_accounting_operations +from app.services.accounting.functions import AccountingOperations +from app.services.accounting.models import AccountingStatus, DocumentUpdate +from app.services.admin.dependencies import require_admin +from app.shared.config.settings import Settings +from app.shared.exceptions import ValidationError + + +def _app(operations: AccountingOperations) -> FastAPI: + app = FastAPI() + app.include_router(accounting_api_router, prefix="/v1") + app.dependency_overrides[require_admin] = lambda: {"sub": "admin"} + app.dependency_overrides[get_accounting_operations] = lambda: operations + return app + + +def test_document_list_forwards_admin_filters() -> None: + operations = AsyncMock(spec=AccountingOperations) + operations.list_documents.return_value = { + "count": 0, + "next": None, + "previous": None, + "results": [], + } + + response = TestClient(_app(operations)).get( + "/v1/admin/accounting/documents?query=invoice&tags=2&tags=3&page_size=25" + ) + + assert response.status_code == 200 + operations.list_documents.assert_awaited_once_with( + {"page": 1, "page_size": 25, "text": "invoice", "tags__id__all": "2,3"} + ) + + +@pytest.mark.asyncio +async def test_status_does_not_call_provider_unless_probe_requested() -> None: + adapter = AsyncMock() + operations = AccountingOperations( + adapter, Settings(paperless_url="http://paperless", paperless_token="secret") + ) + + status = await operations.status() + + assert status == AccountingStatus(configured=True) + adapter.get.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_document_update_only_sends_supplied_fields() -> None: + adapter = AsyncMock() + adapter.patch.return_value = {"id": 7, "title": "Invoice", "tags": []} + operations = AccountingOperations(adapter, Settings()) + + await operations.update_document(7, DocumentUpdate(title="Invoice")) + + adapter.patch.assert_awaited_once_with("documents/7/", {"title": "Invoice"}) + + +@pytest.mark.asyncio +async def test_upload_limit_is_enforced_before_provider_call() -> None: + adapter = AsyncMock() + operations = AccountingOperations(adapter, Settings(paperless_max_upload_bytes=3)) + + with pytest.raises(ValidationError): + await operations.upload("invoice.pdf", "application/pdf", b"1234", {}) + + adapter.post.assert_not_awaited() + + +def test_resource_crud_route_maps_frontend_name_to_paperless_name() -> None: + operations = AsyncMock(spec=AccountingOperations) + operations.create_resource.return_value = {"id": 4, "name": "Invoice"} + + response = TestClient(_app(operations)).post( + "/v1/admin/accounting/resources/document-types", json={"name": "Invoice"} + ) + + assert response.status_code == 201 + operations.create_resource.assert_awaited_once() From def7ba686566a9fe9144809de67d7b3853a6885f Mon Sep 17 00:00:00 2001 From: maltonoloco Date: Wed, 26 Aug 2026 18:39:04 +0200 Subject: [PATCH 2/2] chore(dev): add Paperless development stack --- .env.example | 28 +++++++++++++----- docker-compose.dev.yml | 67 ++++++++++++++++++++++++++++++++++++++++++ docs/accounting.md | 35 ++++++++++++++++++++++ 3 files changed, 122 insertions(+), 8 deletions(-) create mode 100644 docs/accounting.md diff --git a/.env.example b/.env.example index 0dca713..09d0baf 100644 --- a/.env.example +++ b/.env.example @@ -41,14 +41,6 @@ DATABASE_ECHO=false REDIS_URL=redis://localhost:6379/0 # REDIS_PASSWORD= # Load from Docker/K8s secret in production -# ---------------------------------- -# Keycloak -# ---------------------------------- -KEYCLOAK_URL=http://localhost:8080 -KEYCLOAK_REALM=opentaberna -KEYCLOAK_CLIENT_ID=opentaberna-api -# KEYCLOAK_CLIENT_SECRET= # Load from Docker/K8s secret in production - # ---------------------------------- # CORS # ---------------------------------- @@ -145,6 +137,26 @@ MAIL_TIMEOUT_SECONDS=30 # Folder names protected from rename/delete through the admin API. MAIL_PROTECTED_FOLDERS=["INBOX"] +# ---------------------------------- +# Accounting documents (Paperless-ngx) +# ---------------------------------- +# API-facing Paperless connection. docker-compose.dev.yml overrides the URL +# with the internal service address; localhost:8010 is appropriate when the +# API runs directly on the host. +PAPERLESS_URL=http://localhost:8010 +# Create this token in Paperless under My Profile after the first login. +PAPERLESS_TOKEN= +PAPERLESS_TIMEOUT_SECONDS=30 +# Largest accounting document accepted through the API (50 MiB). +PAPERLESS_MAX_UPLOAD_BYTES=52428800 + +# Paperless development-container credentials and persistence services. +# Replace every default below outside local development. +PAPERLESS_ADMIN_USER=admin +PAPERLESS_ADMIN_PASSWORD=admin +PAPERLESS_DB_PASSWORD=paperless +PAPERLESS_SECRET_KEY=CHANGE_ME_IN_PRODUCTION + # ---------------------------------- # Object Storage (MinIO / S3) — carrier label files # ---------------------------------- diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index da2d22e..49facf1 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -13,6 +13,7 @@ services: STORAGE_ENDPOINT_URL: http://opentaberna-minio:9000 OTEL_EXPORTER_OTLP_ENDPOINT: http://opentaberna-otel-collector:4318 OTEL_SERVICE_NAME: opentaberna-api + PAPERLESS_URL: http://opentaberna-paperless:8000 volumes: - stripe_webhook_secret:/run/secrets:ro ports: @@ -36,6 +37,8 @@ services: condition: service_started opentaberna-stripe-listener: condition: service_started + opentaberna-paperless: + condition: service_healthy opentaberna-stripe-listener: build: @@ -211,6 +214,65 @@ services: - "8081:8080" # GreenMail web UI restart: unless-stopped + opentaberna-paperless: + image: ghcr.io/paperless-ngx/paperless-ngx:2.20.15 + environment: + PAPERLESS_REDIS: redis://opentaberna-paperless-redis:6379 + PAPERLESS_DBHOST: opentaberna-paperless-db + PAPERLESS_DBNAME: paperless + PAPERLESS_DBUSER: paperless + PAPERLESS_DBPASS: ${PAPERLESS_DB_PASSWORD:-paperless} + PAPERLESS_TIME_ZONE: Europe/Berlin + PAPERLESS_URL: http://localhost:8010 + PAPERLESS_SECRET_KEY: ${PAPERLESS_SECRET_KEY:-change-me-in-development} + PAPERLESS_ADMIN_USER: ${PAPERLESS_ADMIN_USER:-admin} + PAPERLESS_ADMIN_PASSWORD: ${PAPERLESS_ADMIN_PASSWORD:-admin} + volumes: + - paperless_data:/usr/src/paperless/data + - paperless_media:/usr/src/paperless/media + - paperless_consume:/usr/src/paperless/consume + ports: + - "8010:8000" + restart: unless-stopped + healthcheck: + # /api/status requires a token; use the public login page for liveness. + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/accounts/login/"] + interval: 30s + timeout: 10s + retries: 10 + start_period: 60s + depends_on: + opentaberna-paperless-db: + condition: service_healthy + opentaberna-paperless-redis: + condition: service_healthy + + opentaberna-paperless-db: + image: postgres:17-alpine + environment: + POSTGRES_DB: paperless + POSTGRES_USER: paperless + POSTGRES_PASSWORD: ${PAPERLESS_DB_PASSWORD:-paperless} + volumes: + - paperless_postgres_data:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U paperless -d paperless"] + interval: 10s + timeout: 5s + retries: 5 + + opentaberna-paperless-redis: + image: redis:8-alpine + volumes: + - paperless_redis_data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + # -------------------------------------------------------------------- # Observability (S3). Self-hosted by default; the collector is the seam, # so pointing OTEL_EXPORTER_OTLP_ENDPOINT at a vendor replaces all three @@ -269,3 +331,8 @@ volumes: stripe_webhook_secret: prometheus_data: grafana_data: + paperless_data: + paperless_media: + paperless_consume: + paperless_postgres_data: + paperless_redis_data: diff --git a/docs/accounting.md b/docs/accounting.md new file mode 100644 index 0000000..e83d4a7 --- /dev/null +++ b/docs/accounting.md @@ -0,0 +1,35 @@ +# Accounting documents + +OpenTaberna exposes an admin-only, provider-neutral document API at +`/v1/admin/accounting`. Paperless-ngx performs storage, OCR, full-text search, +classification, and task processing; its credentials never reach the frontend. + +## Development setup + +`docker-compose.dev.yml` starts Paperless at `http://localhost:8010`. Sign in with +`PAPERLESS_ADMIN_USER` / `PAPERLESS_ADMIN_PASSWORD` (both default to `admin` for +local development), create an API token under **My Profile**, and set +`PAPERLESS_TOKEN` in the API `.env`. Production deployments must set unique +`PAPERLESS_SECRET_KEY`, database password, administrator credentials, and a +fixed trusted Paperless image version. + +API configuration: + +- `PAPERLESS_URL` — internal Paperless base URL +- `PAPERLESS_TOKEN` — token used only by the backend adapter +- `PAPERLESS_TIMEOUT_SECONDS` — upstream timeout (default 30) +- `PAPERLESS_MAX_UPLOAD_BYTES` — upload limit (default 50 MiB) + +## Admin frontend surface + +- `GET /status` — configuration and optional connectivity probe +- `GET|POST /documents`, `GET|PATCH|DELETE /documents/{id}` +- `GET /documents/{id}/file?variant=original|preview|thumbnail` +- `POST /documents/bulk-edit` +- `GET /tasks` — poll asynchronous ingestion and bulk operations +- CRUD `/resources/{type}` for tags, correspondents, document types, storage + paths, and custom fields + +Every route requires the existing Keycloak admin role and admin-client token. +Uploads return `202` with a task ID; the frontend should poll `/tasks?task_id=...` +until Paperless reports completion or failure.