mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 15:28:47 +02:00
CSV export for transactions with download buttons (ARCH-18)
GET /api/v1/transactions/export streams a CSV (optional source/type/ date filters), cookie-authenticated so window.open downloads work. Buttons on the Budget transactions tab (all) and Salarios (SALARY only). Quoting and category resolution covered by tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ from datetime import datetime
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import Response as FastAPIResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import and_, or_
|
from sqlalchemy import and_, or_
|
||||||
from sqlmodel import Session, col, func, select
|
from sqlmodel import Session, col, func, select
|
||||||
@@ -21,6 +22,7 @@ from app.models.models import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from app.services.budget_projection import get_cycle_range, get_previous_cycle
|
from app.services.budget_projection import get_cycle_range, get_previous_cycle
|
||||||
|
from app.services.csv_export import build_transactions_csv
|
||||||
from app.services.exchange_rate import get_converted_amount_expr
|
from app.services.exchange_rate import get_converted_amount_expr
|
||||||
|
|
||||||
router = APIRouter(prefix="/transactions", tags=["transactions"])
|
router = APIRouter(prefix="/transactions", tags=["transactions"])
|
||||||
@@ -97,6 +99,33 @@ def list_transactions(
|
|||||||
return session.exec(query).all()
|
return session.exec(query).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export")
|
||||||
|
def export_transactions_csv(
|
||||||
|
source: Optional[TransactionSource] = None,
|
||||||
|
transaction_type: Optional[TransactionType] = None,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Download transactions as CSV (filters optional). Cookie-authenticated,
|
||||||
|
so a plain <a href> download works from the SPA."""
|
||||||
|
csv_text = build_transactions_csv(
|
||||||
|
session,
|
||||||
|
source=source,
|
||||||
|
transaction_type=transaction_type,
|
||||||
|
start_date=datetime.fromisoformat(start_date) if start_date else None,
|
||||||
|
end_date=datetime.fromisoformat(end_date) if end_date else None,
|
||||||
|
)
|
||||||
|
return FastAPIResponse(
|
||||||
|
content=csv_text,
|
||||||
|
media_type="text/csv; charset=utf-8",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": 'attachment; filename="wealthysmart-transactions.csv"'
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/cycles", response_model=list[BillingCycle])
|
@router.get("/cycles", response_model=list[BillingCycle])
|
||||||
def list_billing_cycles(
|
def list_billing_cycles(
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
|
|||||||
57
backend/app/services/csv_export.py
Normal file
57
backend/app/services/csv_export.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""CSV export of transactions (review ARCH-18 / wishlist #4)."""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlmodel import Session, col, select
|
||||||
|
|
||||||
|
from app.models.models import Transaction, TransactionSource, TransactionType
|
||||||
|
|
||||||
|
COLUMNS = [
|
||||||
|
"id", "date", "merchant", "city", "amount", "currency",
|
||||||
|
"transaction_type", "source", "bank", "category", "notes", "reference",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_transactions_csv(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
source: Optional[TransactionSource] = None,
|
||||||
|
transaction_type: Optional[TransactionType] = None,
|
||||||
|
start_date: Optional[datetime] = None,
|
||||||
|
end_date: Optional[datetime] = None,
|
||||||
|
) -> str:
|
||||||
|
q = select(Transaction)
|
||||||
|
if source:
|
||||||
|
q = q.where(Transaction.source == source)
|
||||||
|
if transaction_type:
|
||||||
|
q = q.where(Transaction.transaction_type == transaction_type)
|
||||||
|
if start_date:
|
||||||
|
q = q.where(Transaction.date >= start_date)
|
||||||
|
if end_date:
|
||||||
|
q = q.where(Transaction.date < end_date)
|
||||||
|
q = q.order_by(col(Transaction.date).desc(), col(Transaction.id).desc())
|
||||||
|
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf)
|
||||||
|
writer.writerow(COLUMNS)
|
||||||
|
for tx in session.exec(q).all():
|
||||||
|
writer.writerow(
|
||||||
|
[
|
||||||
|
tx.id,
|
||||||
|
tx.date.isoformat(),
|
||||||
|
tx.merchant,
|
||||||
|
tx.city or "",
|
||||||
|
f"{tx.amount:.2f}",
|
||||||
|
tx.currency.value,
|
||||||
|
tx.transaction_type.value,
|
||||||
|
tx.source.value,
|
||||||
|
tx.bank.value,
|
||||||
|
tx.category.name if tx.category else "",
|
||||||
|
tx.notes or "",
|
||||||
|
tx.reference or "",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return buf.getvalue()
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { type ColumnDef } from '@tanstack/react-table';
|
import { type ColumnDef } from '@tanstack/react-table';
|
||||||
import { Landmark, RefreshCw, Hash, CalendarDays, Banknote } from 'lucide-react';
|
import { Landmark, RefreshCw, Hash, CalendarDays, Banknote, Download } from 'lucide-react';
|
||||||
|
|
||||||
import { type Transaction, type SalariosSummary, getSalarios, getSalariosSummary } from '@/lib/api';
|
import { type Transaction, type SalariosSummary, getSalarios, getSalariosSummary } from '@/lib/api';
|
||||||
import { formatAmount, formatDate } from '@/lib/format';
|
import { formatAmount, formatDate } from '@/lib/format';
|
||||||
@@ -98,6 +98,15 @@ export default function Salarios() {
|
|||||||
<p className="text-sm text-muted-foreground">Historial de depósitos salariales</p>
|
<p className="text-sm text-muted-foreground">Historial de depósitos salariales</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => window.open('/api/v1/transactions/export?transaction_type=SALARY', '_blank')}
|
||||||
|
title="Descargar salarios como CSV"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" aria-hidden="true" />
|
||||||
|
CSV
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" size="icon" onClick={fetchData} title="Refresh" aria-label="Refresh">
|
<Button variant="ghost" size="icon" onClick={fetchData} title="Refresh" aria-label="Refresh">
|
||||||
<RefreshCw className={loading ? 'w-4 h-4 animate-spin' : 'w-4 h-4'} />
|
<RefreshCw className={loading ? 'w-4 h-4 animate-spin' : 'w-4 h-4'} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user