feat: Kategorien-Verwaltung auf der Admin-Seite
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,10 @@
|
|||||||
from fastapi import APIRouter, Depends, Form, Request
|
from fastapi import APIRouter, Depends, Form, Request
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.auth import require_auth
|
from app.auth import require_auth
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
|
from app.models.tables import Category
|
||||||
from app.routers.gui import gui_session, templates
|
from app.routers.gui import gui_session, templates
|
||||||
from app.services.admin import apply_rules_retroactively, change_password
|
from app.services.admin import apply_rules_retroactively, change_password
|
||||||
|
|
||||||
@@ -10,8 +12,12 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/admin", dependencies=[Depends(gui_session)])
|
@router.get("/admin", dependencies=[Depends(gui_session)])
|
||||||
def admin_page(request: Request):
|
def admin_page(request: Request, session: Session = Depends(get_session)):
|
||||||
return templates.TemplateResponse(request, "admin.html", {"error": None, "success": None})
|
return templates.TemplateResponse(request, "admin.html", {
|
||||||
|
"error": None,
|
||||||
|
"success": None,
|
||||||
|
"categories": session.execute(select(Category)).scalars().all(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin/passwort", dependencies=[Depends(gui_session)])
|
@router.post("/admin/passwort", dependencies=[Depends(gui_session)])
|
||||||
@@ -20,18 +26,24 @@ def admin_change_password(
|
|||||||
alt: str = Form(...),
|
alt: str = Form(...),
|
||||||
neu: str = Form(...),
|
neu: str = Form(...),
|
||||||
neu2: str = Form(...),
|
neu2: str = Form(...),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
|
categories = session.execute(select(Category)).scalars().all()
|
||||||
# UX-Regel: das Formular selbst bleibt immer sichtbar/bedienbar; Fehler
|
# UX-Regel: das Formular selbst bleibt immer sichtbar/bedienbar; Fehler
|
||||||
# werden inline auf derselben Seite gemeldet statt still zu verwerfen.
|
# werden inline auf derselben Seite gemeldet statt still zu verwerfen.
|
||||||
|
# Der Kategorien-Abschnitt bleibt aus demselben Grund in JEDEM Render-Pfad
|
||||||
|
# (Fehler wie Erfolg) im Kontext, statt nur bei der GET-Route.
|
||||||
if len(neu) < 8:
|
if len(neu) < 8:
|
||||||
return templates.TemplateResponse(request, "admin.html", {
|
return templates.TemplateResponse(request, "admin.html", {
|
||||||
"error": "Das neue Passwort muss mindestens 8 Zeichen lang sein.",
|
"error": "Das neue Passwort muss mindestens 8 Zeichen lang sein.",
|
||||||
"success": None,
|
"success": None,
|
||||||
|
"categories": categories,
|
||||||
}, status_code=400)
|
}, status_code=400)
|
||||||
if neu != neu2:
|
if neu != neu2:
|
||||||
return templates.TemplateResponse(request, "admin.html", {
|
return templates.TemplateResponse(request, "admin.html", {
|
||||||
"error": "Die Wiederholung stimmt nicht mit dem neuen Passwort überein.",
|
"error": "Die Wiederholung stimmt nicht mit dem neuen Passwort überein.",
|
||||||
"success": None,
|
"success": None,
|
||||||
|
"categories": categories,
|
||||||
}, status_code=400)
|
}, status_code=400)
|
||||||
try:
|
try:
|
||||||
change_password(alt, neu)
|
change_password(alt, neu)
|
||||||
@@ -39,11 +51,13 @@ def admin_change_password(
|
|||||||
return templates.TemplateResponse(request, "admin.html", {
|
return templates.TemplateResponse(request, "admin.html", {
|
||||||
"error": str(exc),
|
"error": str(exc),
|
||||||
"success": None,
|
"success": None,
|
||||||
|
"categories": categories,
|
||||||
}, status_code=400)
|
}, status_code=400)
|
||||||
return templates.TemplateResponse(request, "admin.html", {
|
return templates.TemplateResponse(request, "admin.html", {
|
||||||
"error": None,
|
"error": None,
|
||||||
"success": "Passwort erfolgreich geändert (gilt für GUI und Grafana). "
|
"success": "Passwort erfolgreich geändert (gilt für GUI und Grafana). "
|
||||||
"Bestehende Sitzungen bleiben angemeldet.",
|
"Bestehende Sitzungen bleiben angemeldet.",
|
||||||
|
"categories": categories,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,22 @@ def create_category(data: CategoryIn, session: Session = Depends(get_session)):
|
|||||||
return CategoryOut.model_validate(cat)
|
return CategoryOut.model_validate(cat)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/categories/{category_id}", response_model=CategoryOut)
|
||||||
|
def patch_category(category_id: int, data: CategoryIn,
|
||||||
|
session: Session = Depends(get_session)):
|
||||||
|
cat = session.get(Category, category_id)
|
||||||
|
if cat is None:
|
||||||
|
raise HTTPException(404, "Kategorie nicht gefunden")
|
||||||
|
clash = session.execute(
|
||||||
|
select(Category).where(Category.name == data.name)).scalar()
|
||||||
|
if clash is not None and clash.id != category_id:
|
||||||
|
raise HTTPException(409, "Kategorie existiert bereits")
|
||||||
|
cat.name = data.name
|
||||||
|
session.commit()
|
||||||
|
session.refresh(cat)
|
||||||
|
return CategoryOut.model_validate(cat)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/category-rules", response_model=list[CategoryRuleOut])
|
@router.get("/category-rules", response_model=list[CategoryRuleOut])
|
||||||
def list_category_rules(session: Session = Depends(get_session)):
|
def list_category_rules(session: Session = Depends(get_session)):
|
||||||
rules = session.execute(
|
rules = session.execute(
|
||||||
|
|||||||
@@ -32,6 +32,43 @@
|
|||||||
<p id="apply-rules-result"></p>
|
<p id="apply-rules-result"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="admin-section">
|
||||||
|
<h2>Kategorien</h2>
|
||||||
|
<p class="muted">Kategorien für Buchungen, Fixposten und Szenario-Modifikatoren.
|
||||||
|
Löschen ist bewusst nicht vorgesehen (Kategorien hängen an Buchungen und Regeln).</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Name</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for c in categories %}
|
||||||
|
<tr id="cat-row-{{ c.id }}">
|
||||||
|
<td>{{ c.name }}</td>
|
||||||
|
<td><button type="button" onclick="toggleEdit('cat', {{ c.id }}, true)">Bearbeiten</button></td>
|
||||||
|
</tr>
|
||||||
|
<tr id="cat-edit-{{ c.id }}" hidden>
|
||||||
|
<td colspan="2">
|
||||||
|
<form hx-ext="json-form" hx-patch="/api/categories/{{ c.id }}" hx-swap="none"
|
||||||
|
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||||
|
<label>Name <input type="text" name="name" value="{{ c.name }}" required maxlength="100"></label>
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
<button type="button" onclick="toggleEdit('cat', {{ c.id }}, false)">Abbrechen</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="2">Noch keine Kategorien.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Neue Kategorie anlegen</legend>
|
||||||
|
<form hx-ext="json-form" hx-post="/api/categories" hx-swap="none"
|
||||||
|
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||||
|
<label>Name <input type="text" name="name" required maxlength="100"></label>
|
||||||
|
<button type="submit">Anlegen</button>
|
||||||
|
</form>
|
||||||
|
</fieldset>
|
||||||
|
</section>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function handleApplyRulesResult(event) {
|
function handleApplyRulesResult(event) {
|
||||||
var result = document.getElementById('apply-rules-result');
|
var result = document.getElementById('apply-rules-result');
|
||||||
|
|||||||
@@ -148,14 +148,14 @@
|
|||||||
<span class="muted">— die zur Art passenden Felder werden aktiv, gesperrte Felder sind ausgegraut.</span>
|
<span class="muted">— die zur Art passenden Felder werden aktiv, gesperrte Felder sind ausgegraut.</span>
|
||||||
</div>
|
</div>
|
||||||
<label>Ziel-Typ
|
<label>Ziel-Typ
|
||||||
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)" title="Für diese Eintragsart nicht relevant">
|
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)">
|
||||||
<option value="category">Kategorie</option>
|
<option value="category">Kategorie</option>
|
||||||
<option value="recurring">Wiederkehrender Posten</option>
|
<option value="recurring">Wiederkehrender Posten</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<span class="mod-target-category">
|
<span class="mod-target-category">
|
||||||
<label>Kategorie
|
<label>Kategorie
|
||||||
<select name="target_id" data-type="int" title="Für diese Eintragsart nicht relevant">
|
<select name="target_id" data-type="int">
|
||||||
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
|
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@@ -167,7 +167,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</span>
|
</span>
|
||||||
<label><span class="value-label">Prozentsatz (%)</span> <input type="text" name="value" data-type="amount" value="0" title="Für diese Eintragsart nicht relevant"></label>
|
<label><span class="value-label">Prozentsatz (%)</span> <input type="text" name="value" data-type="amount" value="0"></label>
|
||||||
<label>Endet am <input type="date" name="end_date" disabled title="Für diese Eintragsart nicht relevant"></label>
|
<label>Endet am <input type="date" name="end_date" disabled title="Für diese Eintragsart nicht relevant"></label>
|
||||||
<label>Name <input type="text" name="name" disabled title="Für diese Eintragsart nicht relevant"></label>
|
<label>Name <input type="text" name="name" disabled title="Für diese Eintragsart nicht relevant"></label>
|
||||||
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="5000,00" disabled title="Für diese Eintragsart nicht relevant"></label>
|
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="5000,00" disabled title="Für diese Eintragsart nicht relevant"></label>
|
||||||
@@ -222,7 +222,11 @@
|
|||||||
catSpan.style.display = isCategory ? '' : 'none';
|
catSpan.style.display = isCategory ? '' : 'none';
|
||||||
recSpan.style.display = isCategory ? 'none' : '';
|
recSpan.style.display = isCategory ? 'none' : '';
|
||||||
catSelect.disabled = !isCategory;
|
catSelect.disabled = !isCategory;
|
||||||
|
if (catSelect.disabled) { catSelect.title = 'Für diese Eintragsart nicht relevant'; }
|
||||||
|
else { catSelect.removeAttribute('title'); }
|
||||||
recSelect.disabled = isCategory;
|
recSelect.disabled = isCategory;
|
||||||
|
if (recSelect.disabled) { recSelect.title = 'Für diese Eintragsart nicht relevant'; }
|
||||||
|
else { recSelect.removeAttribute('title'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function onEntryArtChange(select) {
|
function onEntryArtChange(select) {
|
||||||
@@ -234,7 +238,11 @@
|
|||||||
var isMod = art !== 'einmal';
|
var isMod = art !== 'einmal';
|
||||||
function setDisabled(name, disabled) {
|
function setDisabled(name, disabled) {
|
||||||
var el = form.querySelector('[name="' + name + '"]');
|
var el = form.querySelector('[name="' + name + '"]');
|
||||||
if (el) { el.disabled = disabled; }
|
if (el) {
|
||||||
|
el.disabled = disabled;
|
||||||
|
if (disabled) { el.title = 'Für diese Eintragsart nicht relevant'; }
|
||||||
|
else { el.removeAttribute('title'); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setDisabled('value', !(art === 'percent' || art === 'absolute'));
|
setDisabled('value', !(art === 'percent' || art === 'absolute'));
|
||||||
var valueLabel = form.querySelector('.value-label');
|
var valueLabel = form.querySelector('.value-label');
|
||||||
@@ -249,11 +257,21 @@
|
|||||||
var typeSelect = form.querySelector('[name="target_type"]');
|
var typeSelect = form.querySelector('[name="target_type"]');
|
||||||
if (typeSelect) {
|
if (typeSelect) {
|
||||||
typeSelect.disabled = !isMod;
|
typeSelect.disabled = !isMod;
|
||||||
|
if (!isMod) { typeSelect.title = 'Für diese Eintragsart nicht relevant'; }
|
||||||
|
else { typeSelect.removeAttribute('title'); }
|
||||||
var isCategory = typeSelect.value === 'category';
|
var isCategory = typeSelect.value === 'category';
|
||||||
var catSelect = form.querySelector('.mod-target-category select');
|
var catSelect = form.querySelector('.mod-target-category select');
|
||||||
var recSelect = form.querySelector('.mod-target-recurring select');
|
var recSelect = form.querySelector('.mod-target-recurring select');
|
||||||
if (catSelect) { catSelect.disabled = !isMod || !isCategory; }
|
if (catSelect) {
|
||||||
if (recSelect) { recSelect.disabled = !isMod || isCategory; }
|
catSelect.disabled = !isMod || !isCategory;
|
||||||
|
if (catSelect.disabled) { catSelect.title = 'Für diese Eintragsart nicht relevant'; }
|
||||||
|
else { catSelect.removeAttribute('title'); }
|
||||||
|
}
|
||||||
|
if (recSelect) {
|
||||||
|
recSelect.disabled = !isMod || isCategory;
|
||||||
|
if (recSelect.disabled) { recSelect.title = 'Für diese Eintragsart nicht relevant'; }
|
||||||
|
else { recSelect.removeAttribute('title'); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// POST-Ziel nur beim Anlege-Formular wechseln (Edit-Formulare patchen fix).
|
// POST-Ziel nur beim Anlege-Formular wechseln (Edit-Formulare patchen fix).
|
||||||
if (form.hasAttribute('data-planned-url')) {
|
if (form.hasAttribute('data-planned-url')) {
|
||||||
|
|||||||
@@ -234,6 +234,21 @@ def test_admin_passwort_success_returns_200_with_confirmation(
|
|||||||
assert r2.status_code == 200
|
assert r2.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# --- Kategorien-Verwaltung --------------------------------------------------
|
||||||
|
|
||||||
|
def test_admin_zeigt_kategorien_verwaltung(client_with_env_file, db):
|
||||||
|
from app.models.tables import Category
|
||||||
|
client, password = client_with_env_file
|
||||||
|
client.post("/login", data={"username": "admin", "password": password})
|
||||||
|
db.add(Category(name="Admin-Kat"))
|
||||||
|
db.commit()
|
||||||
|
r = client.get("/admin").text
|
||||||
|
assert "Kategorien" in r and "Admin-Kat" in r
|
||||||
|
assert "Neue Kategorie anlegen" in r
|
||||||
|
assert 'hx-patch="/api/categories/' in r
|
||||||
|
assert 'hx-post="/api/categories"' in r
|
||||||
|
|
||||||
|
|
||||||
# --- Regeln neu anwenden ---------------------------------------------------
|
# --- Regeln neu anwenden ---------------------------------------------------
|
||||||
|
|
||||||
def test_apply_rules_retroactively_categorizes_uncategorized_confirmed_tx(db):
|
def test_apply_rules_retroactively_categorizes_uncategorized_confirmed_tx(db):
|
||||||
|
|||||||
@@ -113,6 +113,23 @@ def test_rules_categorize(client):
|
|||||||
assert txs[0]["category_id"] == cat["id"]
|
assert txs[0]["category_id"] == cat["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_category_patch(client):
|
||||||
|
a = client.post("/api/categories", headers=H, json={"name": "Kat-A"}).json()
|
||||||
|
b = client.post("/api/categories", headers=H, json={"name": "Kat-B"}).json()
|
||||||
|
# Umbenennen
|
||||||
|
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-A-neu"})
|
||||||
|
assert r.status_code == 200 and r.json()["name"] == "Kat-A-neu"
|
||||||
|
# Umbenennen auf den EIGENEN Namen ist erlaubt (kein 409)
|
||||||
|
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-A-neu"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
# Kollision mit anderer Kategorie -> 409
|
||||||
|
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-B"})
|
||||||
|
assert r.status_code == 409
|
||||||
|
# unbekannte id -> 404
|
||||||
|
assert client.patch("/api/categories/99999", headers=H,
|
||||||
|
json={"name": "x"}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_patch_account_name(client):
|
def test_patch_account_name(client):
|
||||||
acc = client.post("/api/accounts", headers=H, json={
|
acc = client.post("/api/accounts", headers=H, json={
|
||||||
"bank": "DKB", "iban": "DE71", "name": "DE71", "type": "giro"}).json()
|
"bank": "DKB", "iban": "DE71", "name": "DE71", "type": "giro"}).json()
|
||||||
|
|||||||
Reference in New Issue
Block a user