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 decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict, field_validator
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -27,6 +27,18 @@ class AccountOut(AccountIn):
balance: Decimal = Decimal("0") 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]) @router.get("", response_model=list[AccountOut])
def list_accounts(session: Session = Depends(get_session)): def list_accounts(session: Session = Depends(get_session)):
out = [] out = []
@@ -56,3 +68,16 @@ def create_account(data: AccountIn, session: Session = Depends(get_session)):
session.commit() session.commit()
session.refresh(acc) session.refresh(acc)
return AccountOut.model_validate(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; color: #b00;
} }
.amount {
text-align: right;
}
td.account {
word-break: break-all;
max-width: 12rem;
}
.warning { .warning {
background: #fff3cd; background: #fff3cd;
border: 1px solid #e0c26b; border: 1px solid #e0c26b;

View File

@@ -25,6 +25,36 @@
document.body.addEventListener('htmx:responseError', function (e) { document.body.addEventListener('htmx:responseError', function (e) {
alert('Fehler: ' + (e.detail.xhr.responseText || e.detail.xhr.status)); 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> </script>
</body> </body>
</html> </html>

View File

@@ -16,17 +16,24 @@
<table> <table>
<thead> <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> </thead>
<tbody> <tbody>
{% for account, balance in balances %} {% for account, balance in balances %}
<tr> <tr>
<td>{{ account.name }}</td> <td class="account">{{ account.name }}</td>
<td>{{ account.bank }}</td> <td>{{ account.bank }}</td>
<td class="{{ 'neg' if balance < 0 else '' }}">{{ '%.2f'|format(balance) }} €</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> </tr>
{% else %} {% else %}
<tr><td colspan="3">Keine Konten angelegt.</td></tr> <tr><td colspan="4">Keine Konten angelegt.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View File

@@ -43,17 +43,17 @@
<thead> <thead>
<tr> <tr>
<th>Datum</th><th>Konto</th><th>Verwendungszweck</th><th>Empfänger/Zahler</th> <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> </tr>
</thead> </thead>
<tbody> <tbody>
{% for tx in transactions %} {% for tx in transactions %}
<tr> <tr>
<td>{{ tx.booking_date.strftime('%d.%m.%Y') }}</td> <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.purpose }}</td>
<td>{{ tx.counterparty }}</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> <td>
<select name="category_id" data-type="int" hx-ext="json-form" <select name="category_id" data-type="int" hx-ext="json-form"
hx-patch="/api/transactions/{{ tx.id }}" hx-trigger="change" hx-swap="none"> hx-patch="/api/transactions/{{ tx.id }}" hx-trigger="change" hx-swap="none">
@@ -113,36 +113,4 @@
<button type="submit">Buchung speichern</button> <button type="submit">Buchung speichern</button>
</form> </form>
</fieldset> </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 %} {% endblock %}

View File

@@ -50,6 +50,18 @@ def test_rules_categorize(client):
assert txs[0]["category_id"] == cat["id"] 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): def test_patch_transaction_category(client):
cat = client.post("/api/categories", headers=H, json={"name": "Freizeit"}).json() cat = client.post("/api/categories", headers=H, json={"name": "Freizeit"}).json()
acc = client.post("/api/accounts", headers=H, json={ acc = client.post("/api/accounts", headers=H, json={