commit c6f570100be0997a935946a804924ab561d55e53 Author: Dustin C. Hatch Date: Sun Mar 2 07:47:13 2025 -0600 Initial commit diff --git a/.containerignore b/.containerignore new file mode 100644 index 0000000..e884d12 --- /dev/null +++ b/.containerignore @@ -0,0 +1,5 @@ +* +!.git/ +!index.html +!pyproject.toml +!receipts.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a6efa5c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true + +[*.html] +indent_style = space +indent_size = 4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c7c631 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.egg-info/ +*.py[co] +paperless.token diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..914b230 --- /dev/null +++ b/Containerfile @@ -0,0 +1,37 @@ +FROM git.pyrocufflink.net/containerimages/dch-base AS build + +RUN --mount=type=cache,target=/var/cache \ + microdnf install -y \ + --setopt persistdir=/var/cache/dnf \ + --setopt install_weak_deps=0 \ + git-core \ + python3 \ + uv \ + && : + +WORKDIR /src + +COPY . . + +ENV UV_PROJECT_ENVIRONMENT=/app + +RUN uv sync --no-dev --no-editable + +RUN cp index.html /app/lib/python*/site-packages/ + + +FROM git.pyrocufflink.net/containerimages/dch-base + +RUN --mount=type=cache,target=/var/cache \ + microdnf install -y \ + --setopt persistdir=/var/cache/dnf \ + --setopt install_weak_deps=0 \ + python3 \ + tini \ + && : + +COPY --from=build /app /app + +ENV PATH=/app/bin:/usr/bin + +ENTRYPOINT ["tini", "uvicorn", "--", "receipts:app"] diff --git a/index.html b/index.html new file mode 100644 index 0000000..93df6df --- /dev/null +++ b/index.html @@ -0,0 +1,134 @@ + + + +Upload Receipts + + + + + +
+

Upload Receipts

+
+
+ +
+
+
+
+
+ + +
+

[upload result]

+

[result details]

+
+
+ + + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..573d29e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "receipts" +authors = [{name = "Dustin C. Hatch", email = "dustin@hatch.name"}] +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115.10", + "httpx>=0.28.1", + "python-multipart>=0.0.20", + "uvicorn>=0.34.0", +] +dynamic = ["version"] + +[build-system] +requires = ["setuptools", "setuptools-scm"] +build-backend = "setuptools.build_meta" + +[tool.setuptools_scm] + +[tool.pyright] +venvPath = '.' +venv = '.venv' + +[tool.black] +line-length = 79 +skip-string-normalization = true diff --git a/receipts.py b/receipts.py new file mode 100644 index 0000000..ff8fd62 --- /dev/null +++ b/receipts.py @@ -0,0 +1,129 @@ +import contextlib +import datetime +import importlib.metadata +import logging +import os +from pathlib import Path +from typing import Annotated, BinaryIO, Optional, Self, Type +from types import TracebackType + +import fastapi +import httpx + + +__dist__ = importlib.metadata.metadata(__name__) + +log = logging.getLogger(__name__) + + +PAPERLESS_TOKEN: str +PAPERLESS_URL = os.environ['PAPERLESS_URL'].rstrip('/') + + +router = fastapi.APIRouter() + + +class Paperless: + def __init__(self) -> None: + self.client: Optional[httpx.AsyncClient] = None + + async def __aenter__(self) -> Self: + self.client = httpx.AsyncClient() + return self + + async def __aexit__( + self, + exc_type: Optional[Type[Exception]], + exc_value: Optional[Exception], + tb: Optional[TracebackType], + ) -> None: + if self.client: + await self.client.aclose() + self.client = None + + async def upload( + self, filename: str, image: BinaryIO, date: datetime.date + ) -> str: + assert self.client + log.debug('Sending %s to paperless', filename) + r = await self.client.post( + f'{PAPERLESS_URL}/api/documents/post_document/', + headers={ + 'Authorization': f'Token {PAPERLESS_TOKEN}', + }, + files={ + 'document': (filename, image), + }, + data={ + 'created': date.strftime('%Y-%m-%d'), + }, + ) + r.raise_for_status() + task_id = r.text.strip() + log.info( + 'Successfully uploaded %s to paperless; started consume task %s', + filename, + task_id, + ) + return task_id + + +@router.get('/', response_class=fastapi.responses.HTMLResponse) +def get_form(): + path = Path(__file__).with_name('index.html') + try: + f = path.open('r', encoding='utf-8') + except FileNotFoundError: + raise fastapi.HTTPException( + status_code=fastapi.status.HTTP_404_NOT_FOUND, + ) + with path.open('r', encoding='utf-8') as f: + return f.read() + + +@router.post( + '/', + response_class=fastapi.responses.PlainTextResponse, + status_code=fastapi.status.HTTP_204_NO_CONTENT, +) +async def upload_receipts( + images: Annotated[list[fastapi.UploadFile], fastapi.File(alias='image[]')], + dates: Annotated[list[datetime.date], fastapi.Form(alias='date[]')], + # notes: Annotated[list[str], fastapi.Form(alias='notes[]')], +): + if len(dates) != len(images): + raise fastapi.HTTPException( + status_code=fastapi.status.HTTP_400_BAD_REQUEST, + detail='Number of uploaded images does not match ' + 'number of date fields', + ) + failed = False + async with Paperless() as paperless: + for idx, image in enumerate(images): + date = dates[idx] + filename = image.filename or f'image{idx}' + try: + await paperless.upload(filename, image.file, date) + except Exception as e: + log.error('Failed to send %s to Paperless: %s', filename, e) + failed = True + if failed: + raise fastapi.HTTPException( + status_code=fastapi.status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +@contextlib.asynccontextmanager +async def lifespan(app: fastapi.FastAPI): + global PAPERLESS_TOKEN + PAPERLESS_TOKEN = ( + Path(os.environ['PAPERLESS_TOKEN_FILE']).read_text().strip() + ) + yield + + +app = fastapi.FastAPI( + version=__dist__['version'], + lifespan=lifespan, +) +app.include_router(router)