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.
This commit is contained in:
+88
-36
@@ -6,13 +6,24 @@ import uuid
|
||||
from datetime import date as date_cls
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from flask import Blueprint, g, jsonify, request
|
||||
|
||||
from . import cospend_client, highlight, llm_client, nc_client
|
||||
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
|
||||
@@ -43,9 +54,9 @@ def create_receipt():
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO receipts (id, image_path, items_json, store_name, receipt_date)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(receipt_id, image_path, json.dumps(items), store_name, receipt_date),
|
||||
"""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
|
||||
@@ -53,7 +64,7 @@ def create_receipt():
|
||||
|
||||
@bp.get("/receipts/<receipt_id>")
|
||||
def get_receipt(receipt_id: str):
|
||||
receipt = _load_receipt(receipt_id)
|
||||
receipt = _load_receipt(receipt_id, g.nc_user_id)
|
||||
if receipt is None:
|
||||
return jsonify(error="not found"), 404
|
||||
return jsonify(receipt)
|
||||
@@ -69,8 +80,8 @@ def update_items(receipt_id: str):
|
||||
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"UPDATE receipts SET items_json = ? WHERE id = ?",
|
||||
(json.dumps(items), receipt_id),
|
||||
"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
|
||||
@@ -95,19 +106,24 @@ def update_meta(receipt_id: str):
|
||||
if not fields:
|
||||
return jsonify(error="nothing to update"), 400
|
||||
|
||||
values.append(receipt_id)
|
||||
values.extend([receipt_id, g.nc_user_id])
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(f"UPDATE receipts SET {', '.join(fields)} WHERE id = ?", values)
|
||||
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)
|
||||
receipt = _load_receipt(receipt_id, g.nc_user_id)
|
||||
return jsonify(receipt)
|
||||
|
||||
|
||||
def _load_receipt(receipt_id: str) -> dict | None:
|
||||
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 = ?", (receipt_id,)).fetchone()
|
||||
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 {
|
||||
@@ -125,7 +141,7 @@ def _load_receipt(receipt_id: str) -> dict | None:
|
||||
@bp.get("/cospend/projects")
|
||||
def cospend_projects():
|
||||
try:
|
||||
projects = cospend_client.get_projects()
|
||||
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)
|
||||
@@ -134,7 +150,7 @@ def cospend_projects():
|
||||
@bp.get("/cospend/projects/<project_id>/members")
|
||||
def cospend_members(project_id: str):
|
||||
try:
|
||||
members = cospend_client.get_members(project_id)
|
||||
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)
|
||||
@@ -144,13 +160,16 @@ def cospend_members(project_id: str):
|
||||
|
||||
@bp.post("/receipts/<receipt_id>/groups")
|
||||
def create_group(receipt_id: str):
|
||||
"""Body: {name, cospend_project_id, payer_member_id, member_ids: [...], item_ids: [...]}"""
|
||||
receipt = _load_receipt(receipt_id)
|
||||
"""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 {}
|
||||
name = body.get("name") or "WG"
|
||||
project_id = body.get("cospend_project_id")
|
||||
payer_member_id = body.get("payer_member_id")
|
||||
member_ids = body.get("member_ids") or []
|
||||
@@ -168,13 +187,12 @@ def create_group(receipt_id: str):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO groups
|
||||
(id, receipt_id, name, cospend_project_id, payer_member_id,
|
||||
(id, receipt_id, cospend_project_id, payer_member_id,
|
||||
member_ids_json, item_ids_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
group_id,
|
||||
receipt_id,
|
||||
name,
|
||||
project_id,
|
||||
payer_member_id,
|
||||
json.dumps(member_ids),
|
||||
@@ -188,6 +206,10 @@ def create_group(receipt_id: str):
|
||||
|
||||
@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,)
|
||||
@@ -199,7 +221,6 @@ def _group_dict(row) -> dict:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"receipt_id": row["receipt_id"],
|
||||
"name": row["name"],
|
||||
"cospend_project_id": row["cospend_project_id"],
|
||||
"payer_member_id": row["payer_member_id"],
|
||||
"member_ids": json.loads(row["member_ids_json"]),
|
||||
@@ -213,8 +234,8 @@ def _group_dict(row) -> dict:
|
||||
|
||||
@bp.post("/receipts/<receipt_id>/groups/<group_id>/submit")
|
||||
def submit_group(receipt_id: str, group_id: str):
|
||||
"""Highlights selected items, uploads to NC, shares, creates the Cospend bill."""
|
||||
receipt = _load_receipt(receipt_id)
|
||||
"""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
|
||||
|
||||
@@ -232,33 +253,61 @@ def submit_group(receipt_id: str, group_id: str):
|
||||
return jsonify(error="no matching items on this receipt"), 400
|
||||
|
||||
total = round(sum(item["price"] for item in selected), 2)
|
||||
bboxes = [item["bbox"] for item in selected if item.get("bbox")]
|
||||
|
||||
with open(_receipt_image_path(receipt_id), "rb") as f:
|
||||
with open(_receipt_image_path(receipt_id, g.nc_user_id), "rb") as f:
|
||||
original_bytes = f.read()
|
||||
|
||||
try:
|
||||
highlighted = highlight.highlight_items(original_bytes, bboxes)
|
||||
# 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_name = re.sub(r"[^A-Za-z0-9_-]+", "-", group["name"]).strip("-") or "group"
|
||||
filename = f"{datetime.now():%Y-%m-%d_%H%M%S}_{safe_name}_{group_id[:8]}.jpg"
|
||||
rel_path = nc_client.upload_file(filename, highlighted)
|
||||
share_url = nc_client.create_public_share(rel_path)
|
||||
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 just the
|
||||
# group name - either way followed by the share link, since
|
||||
# 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")
|
||||
label = f"{store_name} ({group['name']})" if store_name else group["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,
|
||||
@@ -285,7 +334,10 @@ def submit_group(receipt_id: str, group_id: str):
|
||||
return jsonify(share_url=share_url, bill_id=bill_id, amount=total)
|
||||
|
||||
|
||||
def _receipt_image_path(receipt_id: str) -> str:
|
||||
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 = ?", (receipt_id,)).fetchone()
|
||||
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"]
|
||||
|
||||
Reference in New Issue
Block a user