Backend: receipt upload -> LLM vision extraction (OpenAI-compatible, provider-agnostic), item review/edit, per-group splitting against Cospend projects/members, highlight+upload via WebDAV, public share link, bill creation via Cospend's OCS API (verified against real source, not just doc summaries). Frontend: capture -> review -> group -> summary flow as an installable PWA. install.sh / run.sh (venv + npm, tmux session) instead of Docker, per the ~/Projects/gain pattern.
77 lines
2.5 KiB
Python
77 lines
2.5 KiB
Python
"""Thin wrapper around the bits of Nextcloud's WebDAV and OCS Share API we need.
|
|
|
|
Auth is HTTP Basic with an app password (Settings -> Security -> Devices &
|
|
Sessions -> create app password), never the account's real password.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import requests
|
|
|
|
from .config import Config
|
|
|
|
|
|
def _auth() -> tuple[str, str]:
|
|
return (Config.NC_USERNAME, Config.NC_APP_PASSWORD)
|
|
|
|
|
|
def _webdav_root() -> str:
|
|
return f"{Config.NC_BASE_URL}/remote.php/dav/files/{Config.NC_USERNAME}"
|
|
|
|
|
|
def ensure_upload_folder() -> None:
|
|
"""MKCOL each segment of the configured upload folder path if missing.
|
|
|
|
NC_UPLOAD_FOLDER may be nested (e.g. "Documents/Cospend/Assets"); MKCOL
|
|
only creates one level at a time, so walk the path segment by segment.
|
|
Idempotent.
|
|
"""
|
|
if not Config.NC_UPLOAD_FOLDER:
|
|
return
|
|
segments = Config.NC_UPLOAD_FOLDER.split("/")
|
|
partial = ""
|
|
for segment in segments:
|
|
partial = f"{partial}/{segment}" if partial else segment
|
|
url = f"{_webdav_root()}/{partial}"
|
|
resp = requests.request("MKCOL", url, auth=_auth(), timeout=15)
|
|
# 201 = created, 405 = already exists. Anything else is a real problem.
|
|
if resp.status_code not in (201, 405):
|
|
resp.raise_for_status()
|
|
|
|
|
|
def upload_file(filename: str, content: bytes, content_type: str = "image/jpeg") -> str:
|
|
"""Uploads `content` to <upload folder>/<filename> via WebDAV PUT.
|
|
|
|
Returns the server-relative path (e.g. "wgBill/receipt-123.jpg"), which is
|
|
what the Share API expects as `path`.
|
|
"""
|
|
ensure_upload_folder()
|
|
rel_path = f"{Config.NC_UPLOAD_FOLDER}/{filename}" if Config.NC_UPLOAD_FOLDER else filename
|
|
url = f"{_webdav_root()}/{rel_path}"
|
|
resp = requests.put(
|
|
url, data=content, auth=_auth(), headers={"Content-Type": content_type}, timeout=30
|
|
)
|
|
resp.raise_for_status()
|
|
return rel_path
|
|
|
|
|
|
def create_public_share(rel_path: str) -> str:
|
|
"""Creates a public read-only link share for `rel_path`. Returns the share URL."""
|
|
url = f"{Config.NC_BASE_URL}/ocs/v2.php/apps/files_sharing/api/v1/shares"
|
|
resp = requests.post(
|
|
url,
|
|
auth=_auth(),
|
|
headers={"OCS-APIRequest": "true"},
|
|
data={
|
|
"path": f"/{rel_path}",
|
|
"shareType": 3, # public link
|
|
"permissions": 1, # read-only
|
|
},
|
|
params={"format": "json"},
|
|
timeout=15,
|
|
)
|
|
resp.raise_for_status()
|
|
payload = resp.json()
|
|
share_url = payload["ocs"]["data"]["url"]
|
|
return share_url
|