feat: Konten umbenennbar, Betrag-Spalte mit Euro im Kopf
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 1–100 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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={
|
||||
|
||||
Reference in New Issue
Block a user