feat: Konten umbenennbar, Betrag-Spalte mit Euro im Kopf

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 14:50:39 +02:00
parent f27eda88f4
commit 36d3e4fc59
6 changed files with 90 additions and 39 deletions

View File

@@ -1,7 +1,7 @@
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, field_validator
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -27,6 +27,18 @@ class AccountOut(AccountIn):
balance: Decimal = Decimal("0")
class AccountPatch(BaseModel):
name: str
@field_validator("name")
@classmethod
def name_not_blank(cls, v: str) -> str:
v = v.strip()
if not v or len(v) > 100:
raise ValueError("Name muss 1100 Zeichen lang sein")
return v
@router.get("", response_model=list[AccountOut])
def list_accounts(session: Session = Depends(get_session)):
out = []
@@ -56,3 +68,16 @@ def create_account(data: AccountIn, session: Session = Depends(get_session)):
session.commit()
session.refresh(acc)
return AccountOut.model_validate(acc)
@router.patch("/{account_id}", response_model=AccountOut)
def patch_account(account_id: int, data: AccountPatch, session: Session = Depends(get_session)):
acc = session.get(Account, account_id)
if acc is None:
raise HTTPException(404, "Konto nicht gefunden")
acc.name = data.name
session.commit()
session.refresh(acc)
item = AccountOut.model_validate(acc)
item.balance = account_balance(session, acc)
return item

View File

@@ -51,6 +51,15 @@ tr:nth-child(even) {
color: #b00;
}
.amount {
text-align: right;
}
td.account {
word-break: break-all;
max-width: 12rem;
}
.warning {
background: #fff3cd;
border: 1px solid #e0c26b;

View File

@@ -25,6 +25,36 @@
document.body.addEventListener('htmx:responseError', function (e) {
alert('Fehler: ' + (e.detail.xhr.responseText || e.detail.xhr.status));
});
// Minimale JSON-Kodierung für htmx-Formulare/Selects, die gegen die JSON-APIs
// (POST/PATCH /api/...) senden. Felder mit data-type="int" werden als Zahl
// kodiert, leere Werte als null, alles andere bleibt String.
htmx.defineExtension('json-form', {
onEvent: function (name, evt) {
if (name === 'htmx:configRequest') {
evt.detail.headers['Content-Type'] = 'application/json';
}
},
encodeParameters: function (xhr, parameters, elt) {
var out = {};
for (var k in parameters) {
var v = parameters[k];
var field = elt.matches && elt.matches('[name="' + k + '"]')
? elt
: elt.querySelector('[name="' + k + '"]');
var kind = field && field.dataset ? field.dataset.type : null;
if (v === '') {
out[k] = null;
} else if (kind === 'int') {
out[k] = parseInt(v, 10);
} else {
out[k] = v;
}
}
xhr.overrideMimeType('text/json');
return JSON.stringify(out);
}
});
</script>
</body>
</html>

View File

@@ -16,17 +16,24 @@
<table>
<thead>
<tr><th>Konto</th><th>Bank</th><th>Saldo</th></tr>
<tr><th>Konto</th><th>Bank</th><th>Saldo</th><th>Umbenennen</th></tr>
</thead>
<tbody>
{% for account, balance in balances %}
<tr>
<td>{{ account.name }}</td>
<td class="account">{{ account.name }}</td>
<td>{{ account.bank }}</td>
<td class="{{ 'neg' if balance < 0 else '' }}">{{ '%.2f'|format(balance) }} €</td>
<td>
<form class="inline-form" hx-ext="json-form" hx-patch="/api/accounts/{{ account.id }}"
hx-swap="none" hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<input type="text" name="name" value="{{ account.name }}" required maxlength="100">
<button type="submit">Umbenennen</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="3">Keine Konten angelegt.</td></tr>
<tr><td colspan="4">Keine Konten angelegt.</td></tr>
{% endfor %}
</tbody>
</table>

View File

@@ -43,17 +43,17 @@
<thead>
<tr>
<th>Datum</th><th>Konto</th><th>Verwendungszweck</th><th>Empfänger/Zahler</th>
<th>Betrag</th><th>Kategorie</th><th></th>
<th>Betrag (€)</th><th>Kategorie</th><th></th>
</tr>
</thead>
<tbody>
{% for tx in transactions %}
<tr>
<td>{{ tx.booking_date.strftime('%d.%m.%Y') }}</td>
<td>{{ account_names.get(tx.account_id, tx.account_id) }}</td>
<td class="account">{{ account_names.get(tx.account_id, tx.account_id) }}</td>
<td>{{ tx.purpose }}</td>
<td>{{ tx.counterparty }}</td>
<td class="{{ 'neg' if tx.amount < 0 else '' }}">{{ '%.2f'|format(tx.amount) }}</td>
<td class="amount {{ 'neg' if tx.amount < 0 else '' }}">{{ '%.2f'|format(tx.amount) }}</td>
<td>
<select name="category_id" data-type="int" hx-ext="json-form"
hx-patch="/api/transactions/{{ tx.id }}" hx-trigger="change" hx-swap="none">
@@ -113,36 +113,4 @@
<button type="submit">Buchung speichern</button>
</form>
</fieldset>
<script>
// Minimale JSON-Kodierung für htmx-Formulare/Selects, die gegen die JSON-APIs
// (POST/PATCH /api/...) senden. Felder mit data-type="int" werden als Zahl
// kodiert, leere Werte als null, alles andere bleibt String.
htmx.defineExtension('json-form', {
onEvent: function (name, evt) {
if (name === 'htmx:configRequest') {
evt.detail.headers['Content-Type'] = 'application/json';
}
},
encodeParameters: function (xhr, parameters, elt) {
var out = {};
for (var k in parameters) {
var v = parameters[k];
var field = elt.matches && elt.matches('[name="' + k + '"]')
? elt
: elt.querySelector('[name="' + k + '"]');
var kind = field && field.dataset ? field.dataset.type : null;
if (v === '') {
out[k] = null;
} else if (kind === 'int') {
out[k] = parseInt(v, 10);
} else {
out[k] = v;
}
}
xhr.overrideMimeType('text/json');
return JSON.stringify(out);
}
});
</script>
{% endblock %}

View File

@@ -50,6 +50,18 @@ def test_rules_categorize(client):
assert txs[0]["category_id"] == cat["id"]
def test_patch_account_name(client):
acc = client.post("/api/accounts", headers=H, json={
"bank": "DKB", "iban": "DE71", "name": "DE71", "type": "giro"}).json()
r = client.patch(f"/api/accounts/{acc['id']}", headers=H,
json={"name": "DKB Giro"})
assert r.status_code == 200 and r.json()["name"] == "DKB Giro"
assert client.patch("/api/accounts/9999", headers=H,
json={"name": "x"}).status_code == 404
assert client.patch(f"/api/accounts/{acc['id']}", headers=H,
json={"name": " "}).status_code == 422
def test_patch_transaction_category(client):
cat = client.post("/api/categories", headers=H, json={"name": "Freizeit"}).json()
acc = client.post("/api/accounts", headers=H, json={