Initial commit
commit
c6f570100b
|
@ -0,0 +1,5 @@
|
||||||
|
*
|
||||||
|
!.git/
|
||||||
|
!index.html
|
||||||
|
!pyproject.toml
|
||||||
|
!receipts.py
|
|
@ -0,0 +1,9 @@
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.html]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
|
@ -0,0 +1,4 @@
|
||||||
|
__pycache__/
|
||||||
|
*.egg-info/
|
||||||
|
*.py[co]
|
||||||
|
paperless.token
|
|
@ -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"]
|
|
@ -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>
|
|
@ -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
|
|
@ -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)
|
Loading…
Reference in New Issue