Skip to content

Commit c27b753

Browse files
authored
Merge pull request #19 from aybruhm/feat/add-bulk-delete-functionality-for-trades
[FEAT]: add bulk delete functionality for trades
2 parents 670c38e + 05d8bab commit c27b753

11 files changed

Lines changed: 186 additions & 4 deletions

File tree

api/adapters/inbound/http/trade_routes.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ class TradeBody(BaseModel):
3232
asset_class: Optional[str] = None
3333

3434

35+
class BulkDeleteRequest(BaseModel):
36+
trade_ids: list[UUID]
37+
38+
3539
def _body_to_request(body: TradeBody) -> CreateTradeRequest:
3640
return CreateTradeRequest(
3741
portfolio_id=body.portfolio_id,
@@ -148,6 +152,24 @@ async def delete_trade(
148152
raise HTTPException(status_code=400, detail=str(e))
149153

150154

155+
@router.post("/bulk/delete", status_code=status.HTTP_204_NO_CONTENT)
156+
async def bulk_delete_trades(
157+
request: BulkDeleteRequest,
158+
_: User = Depends(get_current_user),
159+
session: AsyncSession = Depends(get_session),
160+
):
161+
try:
162+
interactor = TradeInteractor(session)
163+
await interactor.delete_batch_trades(request.trade_ids)
164+
await session.commit()
165+
except ValueError as e:
166+
await session.rollback()
167+
raise HTTPException(status_code=400, detail=str(e))
168+
except Exception as e:
169+
await session.rollback()
170+
raise HTTPException(status_code=400, detail=str(e))
171+
172+
151173
@router.post("/import/validate")
152174
async def validate_csv(
153175
file: UploadFile = File(...),

api/adapters/outbound/persistence/trade_repository.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,17 @@ async def delete(self, trade_id: UUID) -> None:
8686
await self.session.delete(model)
8787
await self.session.flush()
8888

89+
async def delete_batch(self, trade_ids: List[UUID]) -> int:
90+
result = await self.session.execute(
91+
select(TradeModel).where(TradeModel.id.in_(trade_ids))
92+
)
93+
models = result.scalars().all()
94+
deleted_count = len(models)
95+
for model in models:
96+
await self.session.delete(model)
97+
await self.session.flush()
98+
return deleted_count
99+
89100
@staticmethod
90101
def _to_domain(model: TradeModel) -> Trade:
91102
return Trade(

api/application/trades/trade_interactor.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,11 @@ async def delete_trade(self, trade_id: UUID) -> None:
172172

173173
await self.trade_repo.delete(trade_id)
174174

175+
async def delete_batch_trades(self, trade_ids: List[UUID]) -> int:
176+
if not trade_ids:
177+
raise ValueError("No trade IDs provided")
178+
return await self.trade_repo.delete_batch(trade_ids)
179+
175180
@staticmethod
176181
def _trade_to_dict(trade: Trade) -> dict:
177182
return {

api/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "folio-api"
3-
version = "1.3.2"
3+
version = "1.4.0"
44
description = "Add your description here"
55
readme = "README.md"
66
requires-python = ">=3.13"

api/tests/integration/http/test_trade_routes.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,13 @@ async def delete_trade(self, trade_id):
8080
if self.mode == "delete-error":
8181
raise RuntimeError("delete failed")
8282

83+
async def delete_batch_trades(self, trade_ids):
84+
if not trade_ids:
85+
raise ValueError("No trade IDs provided")
86+
if self.mode == "delete-error":
87+
raise RuntimeError("delete failed")
88+
return len(trade_ids)
89+
8390

8491
class FakeCsvImportInteractor:
8592
def __init__(self, mode="happy"):
@@ -337,3 +344,47 @@ def test_trade_create_update_delete_generic_exception_paths(authed_client, monke
337344
_patch_trade_interactor(monkeypatch, FakeTradeInteractor(mode="delete-error"))
338345
delete = authed_client.delete(f"/api/v1/trades/{uuid4()}")
339346
assert delete.status_code == 400
347+
348+
349+
@pytest.mark.integration
350+
@pytest.mark.happy_path
351+
def test_bulk_delete_trades(authed_client, monkeypatch):
352+
interactor = FakeTradeInteractor()
353+
_patch_trade_interactor(monkeypatch, interactor)
354+
trade_ids = [str(uuid4()), str(uuid4()), str(uuid4())]
355+
356+
response = authed_client.post(
357+
"/api/v1/trades/bulk/delete",
358+
json={"trade_ids": trade_ids},
359+
)
360+
361+
assert response.status_code == 204
362+
363+
364+
@pytest.mark.integration
365+
@pytest.mark.edge_case
366+
def test_bulk_delete_trades_empty_list(authed_client, monkeypatch):
367+
interactor = FakeTradeInteractor()
368+
_patch_trade_interactor(monkeypatch, interactor)
369+
370+
response = authed_client.post(
371+
"/api/v1/trades/bulk/delete",
372+
json={"trade_ids": []},
373+
)
374+
375+
assert response.status_code == 400
376+
377+
378+
@pytest.mark.integration
379+
@pytest.mark.grumpy_path
380+
def test_bulk_delete_trades_error(authed_client, monkeypatch):
381+
interactor = FakeTradeInteractor(mode="delete-error")
382+
_patch_trade_interactor(monkeypatch, interactor)
383+
trade_ids = [str(uuid4()), str(uuid4())]
384+
385+
response = authed_client.post(
386+
"/api/v1/trades/bulk/delete",
387+
json={"trade_ids": trade_ids},
388+
)
389+
390+
assert response.status_code == 400

api/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web/package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "folio-web",
3-
"version": "1.3.2",
3+
"version": "1.4.0",
44
"type": "module",
55
"scripts": {
66
"dev": "svelte-kit sync && vite",

web/src/lib/api/controllers/trade.controller.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ export class TradeController {
4545
await this.client.delete(`/trades/${tradeId}`);
4646
}
4747

48+
async deleteBulkTrades(tradeIds: string[]): Promise<void> {
49+
await this.client.post('/trades/bulk/delete', { trade_ids: tradeIds });
50+
}
51+
4852
async validateCsv(
4953
file: File,
5054
mapping: Record<string, unknown>,

web/src/lib/components/TradeTable.svelte

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
88
export let trades: any[] = []
99
export let loading = false
10+
export let selectedIds: Set<string> = new Set()
1011
1112
const columns = [
1213
{ key: 'ticker', label: 'Ticker', sortable: true },
@@ -20,6 +21,7 @@
2021
2122
let sortKey: string | null = null
2223
let sortDesc = false
24+
let selectAll = false
2325
2426
function handleSort(key: string) {
2527
if (sortKey === key) {
@@ -35,6 +37,26 @@
3537
})
3638
}
3739
40+
function toggleSelectAll() {
41+
if (selectAll) {
42+
selectedIds.clear()
43+
trades.forEach(t => selectedIds.add(t.id))
44+
} else {
45+
selectedIds.clear()
46+
}
47+
selectedIds = selectedIds
48+
}
49+
50+
function toggleSelect(tradeId: string) {
51+
if (selectedIds.has(tradeId)) {
52+
selectedIds.delete(tradeId)
53+
} else {
54+
selectedIds.add(tradeId)
55+
}
56+
selectedIds = selectedIds
57+
selectAll = trades.length > 0 && trades.every(t => selectedIds.has(t.id))
58+
}
59+
3860
function getTradeTypeBadge(type: string): 'success' | 'danger' | 'info' | 'warning' | 'default' {
3961
const variants: Record<string, 'success' | 'danger' | 'info' | 'warning'> = {
4062
buy: 'info',
@@ -54,6 +76,15 @@
5476
<table class="w-full text-sm">
5577
<thead class="border-b border-border bg-muted">
5678
<tr>
79+
<th class="h-12 px-4 text-left align-middle font-medium text-muted-foreground w-12">
80+
<input
81+
type="checkbox"
82+
bind:checked={selectAll}
83+
on:change={toggleSelectAll}
84+
class="cursor-pointer"
85+
title="Select all trades"
86+
/>
87+
</th>
5788
{#each columns as col}
5889
<th class="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
5990
{#if col.sortable}
@@ -73,6 +104,14 @@
73104
<tbody class="[&_tr:last-child]:border-0">
74105
{#each trades as row (row.id)}
75106
<tr class="border-b border-border hover:bg-muted/50 transition-colors">
107+
<td class="p-4 align-middle w-12">
108+
<input
109+
type="checkbox"
110+
checked={selectedIds.has(row.id)}
111+
on:change={() => toggleSelect(row.id)}
112+
class="cursor-pointer"
113+
/>
114+
</td>
76115
<td class="p-4 align-middle">{row.ticker}</td>
77116
<td class="p-4 align-middle">{formatDateTime(row.trade_date)}</td>
78117
<td class="p-4 align-middle">

0 commit comments

Comments
 (0)