Initial commit

python
Dustin 2025-03-02 07:47:13 -06:00
commit c6f570100b
7 changed files with 343 additions and 0 deletions

5
.containerignore Normal file
View File

@ -0,0 +1,5 @@
*
!.git/
!index.html
!pyproject.toml
!receipts.py

9
.editorconfig Normal file
View File

@ -0,0 +1,9 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
[*.html]
indent_style = space
indent_size = 4

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
__pycache__/
*.egg-info/
*.py[co]
paperless.token

37
Containerfile Normal file
View File

@ -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"]

134
index.html Normal file
View File

@ -0,0 +1,134 @@
<!DOCTYPE html>
<html>
<head>
<title>Upload Receipts</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" />
<style>
#previews img {
padding: 0.25em;
}
</style>
</head>
<body>
<div class="container">
<h1>Upload Receipts</h1>
<form id="upload-form">
<div>
<input
id="image"
type="file"
name="image[]"
accept="image/*,*.png,*.jpg,*.jpeg,*.jpx"
capture="environment"
multiple
/>
</div>
<div class="grid" id="previews">
</div>
<div><input type="reset"><button id="submit" type="submit" disabled>Submit</button></div>
</div>
</form>
<dialog id="dialog">
<article>
<h2 id="dialog-title">[upload result]</h2>
<p id="dialog-text">[result details]</p>
</article>
</dialog>
<script>
(() => {
const clearPreviews = () => {
while (previews.children.length > 0) {
previews.removeChild(previews.children[0]);
}
}
const setPreviews = () => {
for (const i of image.files) {
const el = document.createElement("div");
const img = document.createElement("img");
img.src = URL.createObjectURL(i);
el.appendChild(img);
const inpDate = document.createElement("input");
inpDate.type = "date";
inpDate.required = true;
inpDate.name = "date[]";
el.appendChild(inpDate);
const inpNotes = document.createElement("input");
inpNotes.name = "notes[]";
inpNotes.placeholder = "Notes ...";
//el.appendChild(inpNotes);
previews.appendChild(el);
}
}
const showDialog = (title, text) => {
dialog_title.innerText = title;
dialog_text.innerText = text;
dialog.show();
};
const image = document.getElementById("image");
const previews = document.getElementById("previews");
const form = document.getElementById("upload-form");
const submit = document.getElementById("submit");
const dialog = document.getElementById("dialog");
const dialog_title = document.getElementById("dialog-title");
const dialog_text = document.getElementById("dialog-text");
image.addEventListener("change", (e) => {
clearPreviews();
setPreviews();
submit.disabled = !image.files;
});
form.addEventListener("reset", () => {
submit.disabled = true;
clearPreviews();
});
form.addEventListener("submit", (e) => {
submit.setAttribute("aria-busy", "true");
const data = new FormData(form);
fetch("/", {
method: "POST",
body: data,
})
.then((r) => {
submit.removeAttribute("aria-busy");
if (r.ok) {
showDialog(
"Upload Success",
"Successfully uploaded receipts"
);
} else {
showDialog(
"Upload Failure",
`Failed to upload receipts: ${r.statusText}`,
);
}
form.reset();
})
.catch((e) => {
submit.removeAttribute("aria-busy");
showDialog("Upload Failure", e.toString());
});
e.preventDefault();
});
document.addEventListener("click", (e) => {
if (dialog.open) {
const elem = dialog.querySelector("*");
if (!elem.contains(e.target)) {
dialog.close();
}
}
});
if (image.files.length > 0) {
setPreviews();
}
})();
</script>
</body>
</html>

25
pyproject.toml Normal file
View File

@ -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

129
receipts.py Normal file
View File

@ -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)