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