Skip to content

Commit 403c00a

Browse files
committed
Upload statements and edit transactions from the browser
Two browser-facing additions that mirror the CLI workflow: Upload: a new /upload page with a drop zone and a multi-file picker posts every selected file to a new /api/upload/ endpoint, which dispatches by extension -- .pdf goes through pdfplumber and the statement importer, .csv goes through the Degiro importer. Each file is processed independently so one bad input cannot abort the batch, and the per-file results are rendered as coloured cards (created / skipped / ignored counts, or an error message). Edit: every row on the Transactions page now has an inline "edit" button that opens a modal with date, description, amount, balance and a grouped category dropdown. Saving PATCHes /api/transactions/<id>/ with the new shape and refreshes the page in place. The serializer exposes category_id as the writable handle while keeping the embedded brief category for read responses, and a flat /api/categories/ endpoint feeds the dropdown.
1 parent 6486d4a commit 403c00a

9 files changed

Lines changed: 582 additions & 7 deletions

File tree

finance/api.py

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,21 @@
77
cashflow, accounts, balance snapshots and a filterable transactions list.
88
"""
99

10+
import tempfile
1011
from datetime import date
1112

1213
from django.db.models import Q, Sum
1314
from django.db.models.functions import TruncMonth
14-
from rest_framework.generics import ListAPIView
15+
from rest_framework.generics import ListAPIView, RetrieveUpdateAPIView
1516
from rest_framework.pagination import PageNumberPagination
17+
from rest_framework.parsers import MultiPartParser
1618
from rest_framework.response import Response
1719
from rest_framework.views import APIView
1820

1921
from finance.models import Account, BalanceSnapshot, Category, PortfolioSnapshot, StatementImport, Transaction
20-
from finance.serializers import TransactionSerializer
22+
from finance.parsers import extract_text
23+
from finance.serializers import CategoryBriefSerializer, TransactionSerializer
24+
from finance.services import import_degiro_csv, import_statement
2125

2226

2327
def _quarter(month):
@@ -627,3 +631,115 @@ def get_queryset(self):
627631
qs = qs.filter(category__isnull=True)
628632

629633
return qs
634+
635+
636+
class TransactionDetailView(RetrieveUpdateAPIView):
637+
"""
638+
Read or update a single transaction.
639+
640+
- GET returns the same shape as the list view (embedded account/category)
641+
- PATCH accepts date, value_date, description, amount, balance, category_id
642+
"""
643+
644+
queryset = Transaction.objects.select_related("account", "category")
645+
serializer_class = TransactionSerializer
646+
647+
648+
class CategoryListView(ListAPIView):
649+
"""
650+
Flat list of categories for the edit dropdown.
651+
652+
- Ordered by kind then name
653+
- Not paginated; the catalogue is small
654+
"""
655+
656+
queryset = Category.objects.all().order_by("kind", "name")
657+
serializer_class = CategoryBriefSerializer
658+
pagination_class = None
659+
660+
661+
def _process_uploaded_file(uploaded):
662+
"""
663+
Run the right importer for one uploaded file based on its extension.
664+
665+
Args:
666+
uploaded (UploadedFile): The file from request.FILES
667+
668+
Returns:
669+
dict: A per-file result row including the filename, the importer used
670+
and the counts the importer reports. On failure the dict carries an
671+
"error" key with a readable message instead.
672+
"""
673+
674+
name = uploaded.name
675+
lower = name.lower()
676+
677+
# Bank-statement PDFs go through pdfplumber, so they need a real file on
678+
# disk; the CSV is small and decodes fine straight from memory.
679+
if lower.endswith(".pdf"):
680+
with tempfile.NamedTemporaryFile(suffix=".pdf") as tmp:
681+
for chunk in uploaded.chunks():
682+
tmp.write(chunk)
683+
tmp.flush()
684+
text = extract_text(tmp.name)
685+
result = import_statement(text, source_file=name)
686+
return {
687+
"file": name,
688+
"type": "statement",
689+
"created": result.created,
690+
"skipped": result.skipped,
691+
"ignored": result.ignored,
692+
"accounts": [a.iban for a in result.accounts],
693+
}
694+
695+
if lower.endswith(".csv"):
696+
text = uploaded.read().decode("utf-8")
697+
result = import_degiro_csv(text, source_file=name)
698+
return {
699+
"file": name,
700+
"type": "degiro_csv",
701+
"created": result["created"],
702+
"skipped": result["skipped"],
703+
"movements": result["movements"],
704+
}
705+
706+
return {"file": name, "error": "Unsupported file type (use .pdf or .csv)"}
707+
708+
709+
class UploadView(APIView):
710+
"""
711+
Accept one or more bank-statement PDFs and/or Degiro CSVs.
712+
713+
- Dispatches by extension: .pdf -> import_statement, .csv -> import_degiro_csv
714+
- Each file is processed independently so one bad file does not abort the batch
715+
- Returns a per-file result list with counts (or an error message)
716+
"""
717+
718+
parser_classes = [MultiPartParser]
719+
720+
def post(self, request):
721+
"""
722+
Process every uploaded file and report per-file results.
723+
724+
Args:
725+
request (Request): The incoming multipart request; expects one or
726+
more files in the "files" field
727+
728+
Returns:
729+
Response: {"results": [...]} with one entry per file
730+
"""
731+
732+
uploads = request.FILES.getlist("files")
733+
if not uploads:
734+
return Response({"results": [], "error": "No files in the request"}, status=400)
735+
736+
results = []
737+
for uploaded in uploads:
738+
try:
739+
results.append(_process_uploaded_file(uploaded))
740+
except Exception as exc: # noqa: BLE001 -- surface any parser/import error to the caller
741+
# We want a single bad file to fail loudly without taking down
742+
# the rest of the batch, so the exception is caught and
743+
# returned as part of that file's result row.
744+
results.append({"file": uploaded.name, "error": str(exc)})
745+
return Response({"results": results})

finance/page_urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@
1717
path("investments/", views.investments, name="investments"),
1818
path("accounts/", views.accounts, name="accounts"),
1919
path("transactions/", views.transactions, name="transactions"),
20+
path("upload/", views.upload, name="upload"),
2021
]

finance/serializers.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,22 @@ class TransactionSerializer(serializers.ModelSerializer):
3535
"""
3636
Serializer for a single transaction, with embedded account and category.
3737
38-
- Used by the transactions list endpoint
39-
- Embeds related rows so the frontend renders without extra requests
38+
- Used by the transactions list endpoint and the detail PATCH endpoint
39+
- Embeds the related account and category as read-only nested objects so
40+
the frontend renders without extra requests
41+
- Exposes category_id as the writable handle for changing the category
4042
"""
4143

4244
account = AccountBriefSerializer(read_only=True)
4345
category = CategoryBriefSerializer(read_only=True)
46+
# Writable category by primary key; null clears the category
47+
category_id = serializers.PrimaryKeyRelatedField(
48+
source="category",
49+
queryset=Category.objects.all(),
50+
write_only=True,
51+
allow_null=True,
52+
required=False,
53+
)
4454

4555
class Meta:
4656
model = Transaction
@@ -53,5 +63,6 @@ class Meta:
5363
"balance",
5464
"account",
5565
"category",
66+
"category_id",
5667
"statement_id",
5768
)

finance/templates/finance/base.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
<a href="{% url 'pages:investments' %}" class="{% if request.resolver_match.url_name == 'investments' %}is-active{% endif %}">Investments</a>
5555
<a href="{% url 'pages:accounts' %}" class="{% if request.resolver_match.url_name == 'accounts' %}is-active{% endif %}">Accounts</a>
5656
<a href="{% url 'pages:transactions' %}" class="{% if request.resolver_match.url_name == 'transactions' %}is-active{% endif %}">Transactions</a>
57+
<a href="{% url 'pages:upload' %}" class="{% if request.resolver_match.url_name == 'upload' %}is-active{% endif %}">Upload</a>
5758
</nav>
5859
<div class="flex items-center gap-2 shrink-0">
5960
<button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle dark mode" title="Toggle dark mode">

finance/templates/finance/transactions.html

Lines changed: 131 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,18 +52,139 @@ <h1 class="text-3xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-10
5252
<tr>
5353
<th>Date</th><th>Account</th><th>Description</th>
5454
<th>Category</th><th class="num">Amount</th><th class="num">Balance</th>
55+
<th></th>
5556
</tr>
5657
</thead>
57-
<tbody id="txn-tbody"><tr><td colspan="6" class="empty">Loading…</td></tr></tbody>
58+
<tbody id="txn-tbody"><tr><td colspan="7" class="empty">Loading…</td></tr></tbody>
5859
</table>
5960
<div class="flex items-center justify-end gap-2 p-3 border-t border-zinc-100 bg-zinc-50/70">
6061
<button type="button" class="btn-ghost" id="prev-page" onclick="window.loadTxns(window.currentPage - 1)">‹ Prev</button>
6162
<button type="button" class="btn-ghost" id="next-page" onclick="window.loadTxns(window.currentPage + 1)">Next ›</button>
6263
</div>
6364
</div>
6465

66+
<!-- Edit modal: hidden until a row's "edit" button is clicked -->
67+
<div id="edit-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-zinc-900/60">
68+
<div class="bg-white dark:bg-zinc-900 rounded-xl shadow-xl w-full max-w-md mx-4 p-5">
69+
<div class="flex items-center justify-between mb-4">
70+
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Edit transaction</h2>
71+
<button type="button" id="edit-close" class="text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100"></button>
72+
</div>
73+
<form id="edit-form" class="grid gap-3">
74+
<input type="hidden" name="id">
75+
<label class="filter-field">Date
76+
<input name="date" type="date" required>
77+
</label>
78+
<label class="filter-field">Description
79+
<input name="description" type="text" required>
80+
</label>
81+
<div class="grid grid-cols-2 gap-3">
82+
<label class="filter-field">Amount
83+
<input name="amount" type="number" step="0.01" required>
84+
</label>
85+
<label class="filter-field">Balance
86+
<input name="balance" type="number" step="0.01">
87+
</label>
88+
</div>
89+
<label class="filter-field">Category
90+
<select name="category_id">
91+
<option value="">— Uncategorised —</option>
92+
</select>
93+
</label>
94+
<div id="edit-error" class="text-sm text-rose-600 hidden"></div>
95+
<div class="flex items-center justify-end gap-2 mt-2">
96+
<button type="button" id="edit-cancel" class="px-3 py-1.5 text-sm rounded-md text-zinc-600 hover:text-zinc-900 dark:hover:text-zinc-100">Cancel</button>
97+
<button type="submit" class="btn-primary">Save</button>
98+
</div>
99+
</form>
100+
</div>
101+
</div>
102+
65103
<script>
66104
window.currentPage = 1;
105+
window.categories = [];
106+
window.txnsById = {};
107+
108+
function csrfToken() {
109+
return (document.cookie.match(/(?:^|; )csrftoken=([^;]+)/) || [])[1] || "";
110+
}
111+
112+
async function loadCategories() {
113+
const d = window.dashboard;
114+
window.categories = await d.fetchJson("/api/categories/");
115+
const select = document.querySelector("#edit-form select[name='category_id']");
116+
// Group options by kind for readability
117+
const byKind = {};
118+
for (const c of window.categories) {
119+
(byKind[c.kind] ||= []).push(c);
120+
}
121+
let html = '<option value="">— Uncategorised —</option>';
122+
for (const kind of Object.keys(byKind).sort()) {
123+
html += `<optgroup label="${kind}">`;
124+
for (const c of byKind[kind]) html += `<option value="${c.id}">${c.name}</option>`;
125+
html += "</optgroup>";
126+
}
127+
select.innerHTML = html;
128+
}
129+
130+
function openEdit(t) {
131+
const form = document.getElementById("edit-form");
132+
form.elements.id.value = t.id;
133+
form.elements.date.value = t.date;
134+
form.elements.description.value = t.description;
135+
form.elements.amount.value = t.amount;
136+
form.elements.balance.value = t.balance ?? "";
137+
form.elements.category_id.value = t.category ? t.category.id : "";
138+
document.getElementById("edit-error").classList.add("hidden");
139+
document.getElementById("edit-modal").classList.remove("hidden");
140+
document.getElementById("edit-modal").classList.add("flex");
141+
}
142+
143+
function closeEdit() {
144+
document.getElementById("edit-modal").classList.add("hidden");
145+
document.getElementById("edit-modal").classList.remove("flex");
146+
}
147+
148+
document.getElementById("edit-close").addEventListener("click", closeEdit);
149+
document.getElementById("edit-cancel").addEventListener("click", closeEdit);
150+
document.getElementById("edit-modal").addEventListener("click", e => {
151+
// Click on backdrop (the modal container itself) closes; clicks inside the panel bubble up but stop here
152+
if (e.target.id === "edit-modal") closeEdit();
153+
});
154+
155+
document.getElementById("edit-form").addEventListener("submit", async e => {
156+
e.preventDefault();
157+
const form = e.currentTarget;
158+
const id = form.elements.id.value;
159+
const payload = {
160+
date: form.elements.date.value,
161+
description: form.elements.description.value,
162+
amount: form.elements.amount.value,
163+
balance: form.elements.balance.value === "" ? null : form.elements.balance.value,
164+
category_id: form.elements.category_id.value === "" ? null : parseInt(form.elements.category_id.value, 10),
165+
};
166+
const err = document.getElementById("edit-error");
167+
err.classList.add("hidden");
168+
try {
169+
const resp = await fetch(`/api/transactions/${id}/`, {
170+
method: "PATCH",
171+
headers: { "Content-Type": "application/json", "X-CSRFToken": csrfToken() },
172+
credentials: "same-origin",
173+
body: JSON.stringify(payload),
174+
});
175+
if (!resp.ok) {
176+
const data = await resp.json().catch(() => ({}));
177+
err.textContent = JSON.stringify(data) || `HTTP ${resp.status}`;
178+
err.classList.remove("hidden");
179+
return;
180+
}
181+
closeEdit();
182+
window.loadTxns(window.currentPage);
183+
} catch (e) {
184+
err.textContent = e.message;
185+
err.classList.remove("hidden");
186+
}
187+
});
67188

68189
window.loadTxns = async function (page) {
69190
const d = window.dashboard;
@@ -76,11 +197,13 @@ <h1 class="text-3xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-10
76197
else if (el.value) params.set(el.name, el.value);
77198
}
78199
const data = await d.fetchJson("/api/transactions/?" + params.toString());
200+
window.txnsById = {};
79201
const tbody = document.getElementById("txn-tbody");
80202
if (!data.results.length) {
81-
tbody.innerHTML = '<tr><td colspan="6" class="empty">No transactions match.</td></tr>';
203+
tbody.innerHTML = '<tr><td colspan="7" class="empty">No transactions match.</td></tr>';
82204
} else {
83205
tbody.innerHTML = data.results.map(t => {
206+
window.txnsById[t.id] = t;
84207
const amt = parseFloat(t.amount);
85208
const bal = t.balance !== null ? d.formatCurrency(parseFloat(t.balance), true) : "—";
86209
const catTag = t.category
@@ -97,8 +220,12 @@ <h1 class="text-3xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-10
97220
<td>${catTag}</td>
98221
<td class="num font-medium ${amt >= 0 ? "text-emerald-600" : "text-rose-600"}">${d.formatCurrency(amt, true)}</td>
99222
<td class="num text-zinc-500">${bal}</td>
223+
<td class="num"><button type="button" data-edit="${t.id}" class="text-xs text-indigo-600 hover:text-indigo-500">edit</button></td>
100224
</tr>`;
101225
}).join("");
226+
tbody.querySelectorAll("button[data-edit]").forEach(btn => {
227+
btn.addEventListener("click", () => openEdit(window.txnsById[btn.dataset.edit]));
228+
});
102229
}
103230
const total = data.count;
104231
const shown = data.results.length;
@@ -108,7 +235,7 @@ <h1 class="text-3xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-10
108235
document.getElementById("next-page").disabled = !data.next;
109236
};
110237

111-
window.addEventListener("load", () => {
238+
window.addEventListener("load", async () => {
112239
const scopeSelect = document.querySelector('#filters select[name="scope"]');
113240
document.querySelectorAll("#view-tabs button").forEach(b => {
114241
b.addEventListener("click", () => {
@@ -118,6 +245,7 @@ <h1 class="text-3xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-10
118245
window.loadTxns(1);
119246
});
120247
});
248+
await loadCategories();
121249
window.loadTxns(1);
122250
});
123251
</script>

0 commit comments

Comments
 (0)