Files
wgBill/backend/app/routes.py
T
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

344 lines
13 KiB
Python

from __future__ import annotations
import json
import re
import uuid
from datetime import date as date_cls
from datetime import datetime
from flask import Blueprint, g, jsonify, request
from . import cospend_client, llm_client, nc_client
from .auth import enforce_login
from .config import Config
from .db import get_conn
bp = Blueprint("api", __name__)
# Every route on this blueprint requires a valid session by default - a
# route added here later is protected automatically, not only if someone
# remembers to decorate it. (auth_routes.py's /auth/* blueprint is
# deliberately separate and unguarded - it's the login mechanism itself.)
bp.before_request(enforce_login)
@bp.get("/me")
def me():
return jsonify(nc_user_id=g.nc_user_id)
# ---------------------------------------------------------------- receipts
@bp.post("/receipts")
def create_receipt():
"""multipart/form-data with an `image` file. Extracts items via LLM."""
file = request.files.get("image")
if file is None:
return jsonify(error="missing 'image' file"), 400
image_bytes = file.read()
receipt_id = str(uuid.uuid4())
image_path = f"{Config.UPLOAD_DIR}/{receipt_id}.jpg"
with open(image_path, "wb") as f:
f.write(image_bytes)
try:
extracted = llm_client.extract_receipt(image_bytes, mime_type=file.mimetype or "image/jpeg")
except Exception as exc: # noqa: BLE001 - surfaced to the client as-is
return jsonify(error=f"extraction failed: {exc}"), 502
items = extracted["items"]
store_name = extracted["store_name"]
# Fall back to today if the receipt's date wasn't readable - the user
# can still override it in the review step.
receipt_date = extracted["date"] or date_cls.today().isoformat()
with get_conn() as conn:
conn.execute(
"""INSERT INTO receipts (id, owner_nc_user_id, image_path, items_json, store_name, receipt_date)
VALUES (?, ?, ?, ?, ?, ?)""",
(receipt_id, g.nc_user_id, image_path, json.dumps(items), store_name, receipt_date),
)
return jsonify(id=receipt_id, items=items, store_name=store_name, date=receipt_date), 201
@bp.get("/receipts/<receipt_id>")
def get_receipt(receipt_id: str):
receipt = _load_receipt(receipt_id, g.nc_user_id)
if receipt is None:
return jsonify(error="not found"), 404
return jsonify(receipt)
@bp.patch("/receipts/<receipt_id>/items")
def update_items(receipt_id: str):
"""Body: {"items": [...]} — full replacement, for the review/edit step."""
body = request.get_json(force=True) or {}
items = body.get("items")
if not isinstance(items, list):
return jsonify(error="'items' must be a list"), 400
with get_conn() as conn:
cur = conn.execute(
"UPDATE receipts SET items_json = ? WHERE id = ? AND owner_nc_user_id = ?",
(json.dumps(items), receipt_id, g.nc_user_id),
)
if cur.rowcount == 0:
return jsonify(error="not found"), 404
return jsonify(id=receipt_id, items=items)
@bp.patch("/receipts/<receipt_id>/meta")
def update_meta(receipt_id: str):
"""Body: {"store_name"?: str, "date"?: "YYYY-MM-DD"} - user override for
what the receipt reader guessed (or didn't find)."""
body = request.get_json(force=True) or {}
fields, values = [], []
if "store_name" in body:
fields.append("store_name = ?")
values.append(body["store_name"])
if "date" in body:
if not re.match(r"^\d{4}-\d{2}-\d{2}$", str(body["date"] or "")):
return jsonify(error="date must be YYYY-MM-DD"), 400
fields.append("receipt_date = ?")
values.append(body["date"])
if not fields:
return jsonify(error="nothing to update"), 400
values.extend([receipt_id, g.nc_user_id])
with get_conn() as conn:
cur = conn.execute(
f"UPDATE receipts SET {', '.join(fields)} WHERE id = ? AND owner_nc_user_id = ?", values
)
if cur.rowcount == 0:
return jsonify(error="not found"), 404
receipt = _load_receipt(receipt_id, g.nc_user_id)
return jsonify(receipt)
def _load_receipt(receipt_id: str, owner_nc_user_id: str) -> dict | None:
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM receipts WHERE id = ? AND owner_nc_user_id = ?",
(receipt_id, owner_nc_user_id),
).fetchone()
if row is None:
return None
return {
"id": row["id"],
"items": json.loads(row["items_json"]),
"store_name": row["store_name"],
"date": row["receipt_date"],
"status": row["status"],
"created_at": row["created_at"],
}
# -------------------------------------------------------------- cospend
@bp.get("/cospend/projects")
def cospend_projects():
try:
projects = cospend_client.get_projects(g.nc_user_id, g.nc_app_password)
except Exception as exc: # noqa: BLE001
return jsonify(error=f"cospend request failed: {exc}"), 502
return jsonify(projects=projects, default_project_id=Config.COSPEND_DEFAULT_PROJECT_ID or None)
@bp.get("/cospend/projects/<project_id>/members")
def cospend_members(project_id: str):
try:
members = cospend_client.get_members(g.nc_user_id, g.nc_app_password, project_id)
except Exception as exc: # noqa: BLE001
return jsonify(error=f"cospend request failed: {exc}"), 502
return jsonify(members)
# ------------------------------------------------------------------ groups
@bp.post("/receipts/<receipt_id>/groups")
def create_group(receipt_id: str):
"""Body: {cospend_project_id, payer_member_id, member_ids: [...], item_ids: [...]}
No user-facing "name" - the group is fully described by the project +
who's in the split, which is already what payer/member_ids capture.
"""
receipt = _load_receipt(receipt_id, g.nc_user_id)
if receipt is None:
return jsonify(error="not found"), 404
body = request.get_json(force=True) or {}
project_id = body.get("cospend_project_id")
payer_member_id = body.get("payer_member_id")
member_ids = body.get("member_ids") or []
item_ids = body.get("item_ids") or []
if not project_id or not payer_member_id or not member_ids or not item_ids:
return (
jsonify(
error="cospend_project_id, payer_member_id, member_ids and item_ids are required"
),
400,
)
group_id = str(uuid.uuid4())
with get_conn() as conn:
conn.execute(
"""INSERT INTO groups
(id, receipt_id, cospend_project_id, payer_member_id,
member_ids_json, item_ids_json)
VALUES (?, ?, ?, ?, ?, ?)""",
(
group_id,
receipt_id,
project_id,
payer_member_id,
json.dumps(member_ids),
json.dumps(item_ids),
),
)
conn.execute("UPDATE receipts SET status = 'grouped' WHERE id = ?", (receipt_id,))
return jsonify(id=group_id), 201
@bp.get("/receipts/<receipt_id>/groups")
def list_groups(receipt_id: str):
# Ownership check on the parent receipt is enough - a group can't exist
# without a receipt row, and receipts are already scoped per-owner.
if _load_receipt(receipt_id, g.nc_user_id) is None:
return jsonify(error="not found"), 404
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM groups WHERE receipt_id = ? ORDER BY created_at", (receipt_id,)
).fetchall()
return jsonify([_group_dict(r) for r in rows])
def _group_dict(row) -> dict:
return {
"id": row["id"],
"receipt_id": row["receipt_id"],
"cospend_project_id": row["cospend_project_id"],
"payer_member_id": row["payer_member_id"],
"member_ids": json.loads(row["member_ids_json"]),
"item_ids": json.loads(row["item_ids_json"]),
"status": row["status"],
"share_url": row["share_url"],
"cospend_bill_id": row["cospend_bill_id"],
"error": row["error"],
}
@bp.post("/receipts/<receipt_id>/groups/<group_id>/submit")
def submit_group(receipt_id: str, group_id: str):
"""Uploads the receipt photo to NC, shares it, creates the Cospend bill."""
receipt = _load_receipt(receipt_id, g.nc_user_id)
if receipt is None:
return jsonify(error="receipt not found"), 404
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM groups WHERE id = ? AND receipt_id = ?", (group_id, receipt_id)
).fetchone()
if row is None:
return jsonify(error="group not found"), 404
group = _group_dict(row)
items_by_id = {item["id"]: item for item in receipt["items"]}
selected = [items_by_id[i] for i in group["item_ids"] if i in items_by_id]
if not selected:
return jsonify(error="no matching items on this receipt"), 400
total = round(sum(item["price"] for item in selected), 2)
with open(_receipt_image_path(receipt_id, g.nc_user_id), "rb") as f:
original_bytes = f.read()
try:
# Members list is fetched here (not stored at group-creation time) so
# the label always reflects real names - also gives us the payer's
# name for a slightly more readable title.
members = cospend_client.get_members(
g.nc_user_id, g.nc_app_password, group["cospend_project_id"]
)
names_by_id = {str(m["id"]): m["name"] for m in members}
# member ids come back from Cospend as JSON numbers, not strings, and
# may have round-tripped through JS (which doesn't distinguish) - str()
# everything here so a missed match can't leave a raw int in the list
# `join` chokes on.
ower_names = [names_by_id.get(str(m), str(m)) for m in group["member_ids"]]
members_label = " + ".join(ower_names) if ower_names else "split"
# No highlighting - LLM-provided bounding boxes were unreliable
# enough on real receipts to not be worth it. Just upload the
# original photo, into the submitting user's own NC files.
# Timestamp for human sorting/browsing in NC, group id suffix so two
# submits in the same second (or a retry) never collide/overwrite.
safe_label = re.sub(r"[^A-Za-z0-9_-]+", "-", members_label).strip("-") or "group"
filename = f"{datetime.now():%Y-%m-%d_%H%M%S}_{safe_label}_{group_id[:8]}.jpg"
rel_path = nc_client.upload_file(
g.nc_user_id, g.nc_app_password, filename, original_bytes
)
share_url = nc_client.create_public_share(g.nc_user_id, g.nc_app_password, rel_path)
item_lines = "\n".join(f"- {i['label']}: {i['price']}" for i in selected)
# Descriptive title: store name when we have one, else fall back to
# who's splitting it - either way followed by the share link, since
# Cospend's UI only recognizes a bill as having an attachment when
# the link is in `what` (the title); a link in `comment` is just
# plain text and isn't picked up.
# Only mention who it's split with when there's more than one bill
# off this receipt to tell apart - with a single bill it's just
# noise (the split is already visible in the bill itself).
with get_conn() as conn:
(bill_count,) = conn.execute(
"SELECT COUNT(*) FROM groups WHERE receipt_id = ?", (receipt_id,)
).fetchone()
store_name = receipt.get("store_name")
if bill_count > 1:
label = f"{store_name} ({members_label})" if store_name else members_label
else:
label = store_name or "receipt"
bill_date = receipt.get("date") or date_cls.today().isoformat()
bill_id = cospend_client.create_bill(
g.nc_user_id,
g.nc_app_password,
project_id=group["cospend_project_id"],
what=f"{label} {share_url}",
amount=total,
payer_id=int(group["payer_member_id"]),
ower_ids=[int(m) for m in group["member_ids"]],
date=bill_date,
comment=item_lines,
)
except Exception as exc: # noqa: BLE001
with get_conn() as conn:
conn.execute(
"UPDATE groups SET status = 'failed', error = ? WHERE id = ?",
(str(exc), group_id),
)
return jsonify(error=f"submit failed: {exc}"), 502
with get_conn() as conn:
conn.execute(
"""UPDATE groups SET status = 'submitted', share_url = ?, cospend_bill_id = ?
WHERE id = ?""",
(share_url, str(bill_id), group_id),
)
return jsonify(share_url=share_url, bill_id=bill_id, amount=total)
def _receipt_image_path(receipt_id: str, owner_nc_user_id: str) -> str:
with get_conn() as conn:
row = conn.execute(
"SELECT image_path FROM receipts WHERE id = ? AND owner_nc_user_id = ?",
(receipt_id, owner_nc_user_id),
).fetchone()
return row["image_path"]