phase B (2/2): CSV upload endpoint + drag-drop UI
Completes Phase B. The full alternative-onboarding flow is now end-to-end: drop a T212 pie CSV → parser → InstrumentMap resolver → PortfolioSnapshot + Position rows, all without ever asking the user for broker credentials. - persist_pie() in app/services/csv_import.py: takes a ParsedPie, resolves each Slice via InstrumentMap, writes Portfolio + Snapshot + Position rows. Unmapped slices are still persisted using their CSV values and surfaced in the response for the UI to warn about. - POST /api/portfolios/upload: multipart endpoint accepting CSV file + optional portfolio_name + currency. 2 MiB cap. Returns import summary. - /upload page with drag-drop dropzone, file input fallback, and inline result panel showing invested/value/result + unmapped-slice warnings. - New "Import" link in the header nav. Verified end-to-end against the real T212 export: all 13 positions land with correct T212 tickers (incl. FPp_EQ for the Paris TotalEnergies listing the heuristic resolver picks), zero unmapped slices, totals reconcile to the penny. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
16e9f5f0cc
commit
8a155ef157
6 changed files with 439 additions and 12 deletions
|
|
@ -9,7 +9,7 @@ import calendar as _cal
|
|||
import re
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -383,6 +383,66 @@ async def log_days(
|
|||
# --- Portfolios --------------------------------------------------------------
|
||||
|
||||
|
||||
# 2 MiB max for CSV uploads — T212 pies don't exceed a few KB in practice.
|
||||
# Keeps the abuse vector small without rejecting legitimate exports.
|
||||
_MAX_CSV_BYTES = 2 * 1024 * 1024
|
||||
|
||||
|
||||
@router.post("/portfolios/upload")
|
||||
async def upload_portfolio_csv(
|
||||
file: UploadFile = File(...),
|
||||
portfolio_name: str | None = Form(default=None),
|
||||
currency: str = Form(default="GBP"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Import a Trading 212 pie-export CSV. Parses, resolves each Slice to a
|
||||
T212 ticker + Yahoo symbol via InstrumentMap, and persists a new
|
||||
PortfolioSnapshot + Position rows.
|
||||
|
||||
No user-id scoping yet — that lands in phase C. Until then, all uploads
|
||||
land in the single shared portfolio identified by name."""
|
||||
from app.services.csv_import import CSVImportError, parse_t212_csv, persist_pie
|
||||
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="No file uploaded")
|
||||
if not file.filename.lower().endswith(".csv"):
|
||||
raise HTTPException(status_code=400, detail="File must have .csv extension")
|
||||
|
||||
raw = await file.read(_MAX_CSV_BYTES + 1)
|
||||
if len(raw) > _MAX_CSV_BYTES:
|
||||
raise HTTPException(status_code=413, detail=f"File exceeds {_MAX_CSV_BYTES} bytes")
|
||||
if not raw:
|
||||
raise HTTPException(status_code=400, detail="File is empty")
|
||||
|
||||
try:
|
||||
pie = parse_t212_csv(raw)
|
||||
except CSVImportError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
try:
|
||||
result = await persist_pie(
|
||||
session, pie,
|
||||
portfolio_name=portfolio_name,
|
||||
currency=currency,
|
||||
)
|
||||
except Exception as e:
|
||||
# Roll back; surface a clean error
|
||||
await session.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Persist failed: {e}")
|
||||
|
||||
return {
|
||||
"portfolio_id": result.portfolio_id,
|
||||
"snapshot_id": result.snapshot_id,
|
||||
"portfolio_name": result.portfolio_name,
|
||||
"is_new_portfolio": result.is_new_portfolio,
|
||||
"positions": result.positions_written,
|
||||
"unmapped": result.unmapped_slices,
|
||||
"invested": pie.invested,
|
||||
"value": pie.value,
|
||||
"result": pie.result,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/portfolios")
|
||||
async def portfolios(
|
||||
request: Request,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,12 @@ async def news_page(request: Request):
|
|||
return templates.TemplateResponse(request, "news.html", {})
|
||||
|
||||
|
||||
@router.get("/upload", response_class=HTMLResponse)
|
||||
async def upload_page(request: Request):
|
||||
"""Drag-drop CSV import. Posts to /api/portfolios/upload."""
|
||||
return templates.TemplateResponse(request, "upload.html", {})
|
||||
|
||||
|
||||
async def _resolve_log_date(session: AsyncSession, day: str | None) -> date:
|
||||
"""If `day` is YYYY-MM-DD use it; else fall back to the date of the most
|
||||
recent generated log; else today."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue