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 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 1–100 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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
Reference in New Issue
Block a user