Files
wgBill/backend/app/nc_client.py
T
dodox d97aac0e17 Initial scaffold: Flask/SQLite backend, React/Vite PWA frontend
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.
2026-08-30 15:02:18 +02:00

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