sinks: switch Wealthfolio import to JSON body (not multipart CSV)
/api/v1/activities/import expects Content-Type: application/json with body
{"activities": [ActivityImport, ...]} where ActivityImport is camelCase:
{date, symbol, activityType, quantity?, unitPrice?, currency, fee?, amount?,
comment?, accountId?, ...}. Source: crates/core/src/activities/activities_model.rs
Live run failure was HTTP 415 Unsupported Media Type because we were uploading
a CSV in multipart/form-data; that endpoint is JSON-only.
Also handle two response shapes on import — older builds return a list, current
build returns {activities: [...]}.
Test plan:
poetry run pytest -q → 70 passed
poetry run mypy → clean
poetry run ruff check → clean
This commit is contained in:
parent
ea881e272b
commit
1d23bf6ed7
1 changed files with 46 additions and 19 deletions
|
|
@ -1,7 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
|
|
@ -149,22 +147,34 @@ class WealthfolioSink:
|
|||
# -- activity import --
|
||||
|
||||
@staticmethod
|
||||
def _activities_csv(activities: Iterable[Activity]) -> str:
|
||||
activities = list(activities)
|
||||
if not activities:
|
||||
return ""
|
||||
rows = [a.to_wealthfolio_csv_row() for a in activities]
|
||||
buf = io.StringIO()
|
||||
w = csv.DictWriter(buf, fieldnames=list(rows[0].keys()))
|
||||
w.writeheader()
|
||||
w.writerows(rows)
|
||||
return buf.getvalue()
|
||||
def _activity_to_import_row(a: Activity) -> dict[str, Any]:
|
||||
"""Match Wealthfolio's ActivityImport struct (camelCase JSON)."""
|
||||
row: dict[str, Any] = {
|
||||
"date": a.date.isoformat(),
|
||||
"symbol": a.symbol or "$CASH",
|
||||
"activityType": str(a.activity_type),
|
||||
"currency": a.currency,
|
||||
"accountId": a.account_id,
|
||||
}
|
||||
if a.quantity is not None:
|
||||
row["quantity"] = format(a.quantity, "f")
|
||||
if a.unit_price is not None:
|
||||
row["unitPrice"] = format(a.unit_price, "f")
|
||||
if a.amount is not None:
|
||||
row["amount"] = format(a.amount, "f")
|
||||
if a.fee:
|
||||
row["fee"] = format(a.fee, "f")
|
||||
if a.notes:
|
||||
row["comment"] = a.notes
|
||||
return row
|
||||
|
||||
async def import_activities(self, activities: Iterable[Activity]) -> list[dict[str, Any]]:
|
||||
csv_text = self._activities_csv(activities)
|
||||
files = {"file": ("activities.csv", csv_text, "text/csv")}
|
||||
rows = [self._activity_to_import_row(a) for a in activities]
|
||||
if not rows:
|
||||
return []
|
||||
body = {"activities": rows}
|
||||
|
||||
check = await self._request("POST", _IMPORT_CHECK, files=files)
|
||||
check = await self._request("POST", _IMPORT_CHECK, json=body)
|
||||
if check.status_code >= 400:
|
||||
try:
|
||||
payload = check.json()
|
||||
|
|
@ -172,9 +182,26 @@ class WealthfolioSink:
|
|||
payload = {"raw": check.text}
|
||||
raise ImportValidationError(f"Wealthfolio /import/check rejected: {payload}")
|
||||
|
||||
# Re-send the same CSV to the real endpoint.
|
||||
real = await self._request("POST", _IMPORT_REAL, files=files)
|
||||
# Dry-run may flag per-row errors inside a 200 response.
|
||||
checked = check.json()
|
||||
if isinstance(checked, list):
|
||||
bad = [r for r in checked if isinstance(r, dict) and r.get("errors")]
|
||||
if bad:
|
||||
# Show the first few to aid debugging without leaking everything.
|
||||
raise ImportValidationError(
|
||||
f"Wealthfolio /import/check flagged {len(bad)} rows; "
|
||||
f"first: {bad[0]}"
|
||||
)
|
||||
|
||||
real = await self._request("POST", _IMPORT_REAL, json=body)
|
||||
real.raise_for_status()
|
||||
raw = real.json()
|
||||
assert isinstance(raw, list)
|
||||
return raw
|
||||
# import_activities returns ImportActivitiesResult which is an object,
|
||||
# but we also accept a list (older versions). Normalise.
|
||||
if isinstance(raw, dict) and "activities" in raw:
|
||||
got = raw["activities"]
|
||||
assert isinstance(got, list)
|
||||
return got
|
||||
if isinstance(raw, list):
|
||||
return raw
|
||||
return []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue