Files
wgBill/backend/app/cospend_client.py
dodox 1b38396a2c Per-user auth via Nextcloud Login Flow v2, Tailwind UI rewrite
- auth.py/auth_routes.py: Login Flow v2 (verified against real NC
  source/docs) - no OAuth2 client registration needed, backend polls
  server-side so no CORS issues. Sessions are hashed-token cookies;
  NC app passwords encrypted at rest (Fernet). Every /api/* route
  guarded by a blueprint-wide before_request, not per-route decorators,
  so future routes are protected by default.
- receipts/groups scoped per owner_nc_user_id; cross-user access 404s.
- nc_client/cospend_client take (username, app_password) per call
  instead of one shared global credential - each user's uploads/shares/
  bills now happen as themselves.
- Frontend: LoginGate component drives the login flow (open NC login
  in a new tab, poll our backend, done).
- Merged the old separate review step into the split screen, redesigned
  with Tailwind (was unstyled/broken), default-excluded-per-item
  splitting with one-click "include everyone" fixed, DD.MM.YYYY date
  field, receipt-icon branding.
- Dropped LLM bounding-box highlighting - unreliable on real receipts,
  plain photo upload instead.
- Only mention who a bill is split with in its title when there's more
  than one bill off the same receipt to disambiguate.
2026-08-30 16:02:29 +02:00

102 lines
3.2 KiB
Python

"""Cospend's authenticated (NC-login) API - same per-user app-password auth
as nc_client (see auth.py). This is an OCS API (same family as the Share
API), confirmed against the real routes/controller source in
julien-nc/cospend-nc:
appinfo/routes.php ('ocs' section):
GET /api/{v}/projects -> api#getLocalProjects
GET /api/{v}/projects/{projectId}/members -> api#getMembers
POST /api/{v}/projects/{projectId}/bills -> api#createBill
lib/Controller/ApiController.php, createBill() signature:
string $projectId, ?string $date, ?string $what, ?int $payer,
?string $payedFor, ?float $amount, ... ?string $comment, ...
-> params are camelCase (payedFor, not payed_for), payer/payedFor are
numeric member ids (payedFor is a comma-separated string of ids).
Being an OCS controller, every call needs the OCS-APIRequest header and the
full path is under /ocs/v2.php/apps/cospend/...
"""
from __future__ import annotations
from typing import Any
import requests
from .config import Config
_HEADERS = {"OCS-APIRequest": "true"}
def _base(project_id: str | None = None) -> str:
root = f"{Config.NC_BASE_URL}/ocs/v2.php/apps/cospend/api/v1"
if project_id is None:
return root
return f"{root}/projects/{project_id}"
def get_projects(username: str, app_password: str) -> list[dict[str, Any]]:
"""Lists Cospend projects visible to the authenticated user."""
resp = requests.get(
f"{_base()}/projects",
auth=(username, app_password),
headers=_HEADERS,
params={"format": "json"},
timeout=15,
)
resp.raise_for_status()
return resp.json()["ocs"]["data"]
def get_members(username: str, app_password: str, project_id: str) -> list[dict[str, Any]]:
resp = requests.get(
f"{_base(project_id)}/members",
auth=(username, app_password),
headers=_HEADERS,
params={"format": "json"},
timeout=15,
)
resp.raise_for_status()
return resp.json()["ocs"]["data"]
def create_bill(
username: str,
app_password: str,
*,
project_id: str,
what: str,
amount: float,
payer_id: int,
ower_ids: list[int],
date: str,
comment: str = "",
) -> int:
"""Creates a bill split evenly among `ower_ids`, paid by `payer_id`.
`amount` is the bill total; Cospend divides it across owers itself.
Returns the new bill's id - createBill's controller method returns just
the int id (`return new DataResponse($newBillId)`), not a bill object.
"""
resp = requests.post(
f"{_base(project_id)}/bills",
auth=(username, app_password),
headers=_HEADERS,
params={"format": "json"},
json={
"what": what,
"amount": amount,
"payer": payer_id,
"payedFor": ",".join(str(i) for i in ower_ids),
"comment": comment,
"date": date, # YYYY-MM-DD, the receipt's issue date, not today
# required by LocalProjectService::createBill (400s without it);
# 'n' = FREQUENCY_NO, i.e. this bill doesn't repeat.
"repeat": "n",
},
timeout=15,
)
resp.raise_for_status()
return resp.json()["ocs"]["data"]