Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 20 additions & 8 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ----------------------------------
Expand Down Expand Up @@ -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
# ----------------------------------
Expand Down
67 changes: 67 additions & 0 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -36,6 +37,8 @@ services:
condition: service_started
opentaberna-stripe-listener:
condition: service_started
opentaberna-paperless:
condition: service_healthy

opentaberna-stripe-listener:
build:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -269,3 +331,8 @@ volumes:
stripe_webhook_secret:
prometheus_data:
grafana_data:
paperless_data:
paperless_media:
paperless_consume:
paperless_postgres_data:
paperless_redis_data:
35 changes: 35 additions & 0 deletions docs/accounting.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions src/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
10 changes: 10 additions & 0 deletions src/app/services/accounting/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
4 changes: 4 additions & 0 deletions src/app/services/accounting/adapters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .interface import AccountingAdapter, Download
from .paperless_adapter import PaperlessAccountingAdapter

__all__ = ["AccountingAdapter", "Download", "PaperlessAccountingAdapter"]
36 changes: 36 additions & 0 deletions src/app/services/accounting/adapters/interface.py
Original file line number Diff line number Diff line change
@@ -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: ...
90 changes: 90 additions & 0 deletions src/app/services/accounting/adapters/paperless_adapter.py
Original file line number Diff line number Diff line change
@@ -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,
)
22 changes: 22 additions & 0 deletions src/app/services/accounting/dependencies.py
Original file line number Diff line number Diff line change
@@ -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"]
3 changes: 3 additions & 0 deletions src/app/services/accounting/functions/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .accounting_operations import AccountingOperations

__all__ = ["AccountingOperations"]
Loading
Loading