feat: Szenario per GUI löschen, Name System-Ist reserviert (A11-T1)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 19:01:21 +02:00
co-authored by Claude Opus 5.5
parent 1c452b48de
commit 57ab6dae4a
5 changed files with 64 additions and 0 deletions
+5
View File
@@ -1,6 +1,11 @@
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
# Serienname der Ist-Kurve im Grafana-Panel „Szenario-Vergleich" (v_projection).
# Für Szenarien reserviert (routers/scenarios.py lehnt ihn ab), damit kein
# Szenario mit der Ist-Serie verschmelzen kann (Ausbaustufe 11).
RESERVED_SCENARIO_NAME = "System-Ist"
# Per-account offset for the Konto-Saldo-Anker (Ausbaustufe 3 Task 1): the # Per-account offset for the Konto-Saldo-Anker (Ausbaustufe 3 Task 1): the
# same math as app.services.balances.account_balance, expressed as a single # same math as app.services.balances.account_balance, expressed as a single
# constant "base" per account so it can be added to a plain (anchor-agnostic) # constant "base" per account so it can be added to a plain (anchor-agnostic)
+11
View File
@@ -13,6 +13,7 @@ from app.db import get_session
from app.models.tables import (Loan, ProjectionPoint, ProjectionResult, from app.models.tables import (Loan, ProjectionPoint, ProjectionResult,
Scenario, ScenarioLoan, ScenarioModifier, Scenario, ScenarioLoan, ScenarioModifier,
ScenarioPlannedItem) ScenarioPlannedItem)
from app.models.views import RESERVED_SCENARIO_NAME
from app.services.projection_service import run_projection from app.services.projection_service import run_projection
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"], router = APIRouter(prefix="/api/scenarios", tags=["scenarios"],
@@ -85,6 +86,14 @@ def _get_scenario(session: Session, scenario_id: int) -> Scenario:
return scenario return scenario
def _check_reserved_name(name: str | None) -> None:
"""Der Serienname der Ist-Kurve (v_projection) ist für Szenarien tabu."""
if name is not None and (name.strip().casefold()
== RESERVED_SCENARIO_NAME.casefold()):
raise HTTPException(
422, f"Der Name »{RESERVED_SCENARIO_NAME}« ist für die Ist-Kurve reserviert")
@router.get("", response_model=list[ScenarioOut]) @router.get("", response_model=list[ScenarioOut])
def list_scenarios(session: Session = Depends(get_session)): def list_scenarios(session: Session = Depends(get_session)):
return [ScenarioOut.model_validate(s) return [ScenarioOut.model_validate(s)
@@ -93,6 +102,7 @@ def list_scenarios(session: Session = Depends(get_session)):
@router.post("", response_model=ScenarioOut, status_code=201) @router.post("", response_model=ScenarioOut, status_code=201)
def create_scenario(data: ScenarioIn, session: Session = Depends(get_session)): def create_scenario(data: ScenarioIn, session: Session = Depends(get_session)):
_check_reserved_name(data.name)
if session.execute(select(Scenario).where(Scenario.name == data.name)).scalar(): if session.execute(select(Scenario).where(Scenario.name == data.name)).scalar():
raise HTTPException(409, "Szenario existiert bereits") raise HTTPException(409, "Szenario existiert bereits")
scenario = Scenario(**data.model_dump()) scenario = Scenario(**data.model_dump())
@@ -107,6 +117,7 @@ def patch_scenario(scenario_id: int, data: ScenarioPatch,
session: Session = Depends(get_session)): session: Session = Depends(get_session)):
scenario = _get_scenario(session, scenario_id) scenario = _get_scenario(session, scenario_id)
fields = data.model_dump(exclude_unset=True) fields = data.model_dump(exclude_unset=True)
_check_reserved_name(fields.get("name"))
if "name" in fields and fields["name"] != scenario.name: if "name" in fields and fields["name"] != scenario.name:
clash = session.execute( clash = session.execute(
select(Scenario).where(Scenario.name == fields["name"])).scalar() select(Scenario).where(Scenario.name == fields["name"])).scalar()
+3
View File
@@ -13,6 +13,9 @@
· Wiederkehrende Posten: {{ 'ja' if sc.include_recurring else 'nein' }} · Wiederkehrende Posten: {{ 'ja' if sc.include_recurring else 'nein' }}
· Einmalposten: {{ 'ja' if sc.include_planned else 'nein' }} · Einmalposten: {{ 'ja' if sc.include_planned else 'nein' }}
<button type="button" onclick="toggleEdit('sc', {{ sc.id }}, true)">Bearbeiten</button> <button type="button" onclick="toggleEdit('sc', {{ sc.id }}, true)">Bearbeiten</button>
<button type="button" hx-delete="/api/scenarios/{{ sc.id }}" hx-swap="none"
hx-confirm="Szenario „{{ sc.name }}" mit allen Einträgen wirklich löschen?"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">Löschen</button>
</p> </p>
<div id="sc-edit-{{ sc.id }}" hidden> <div id="sc-edit-{{ sc.id }}" hidden>
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}" hx-swap="none" <form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}" hx-swap="none"
+24
View File
@@ -1,6 +1,8 @@
from datetime import date, timedelta from datetime import date, timedelta
from decimal import Decimal from decimal import Decimal
import pytest
from app.models.tables import Account, Transaction from app.models.tables import Account, Transaction
from app.services.balances import account_balance from app.services.balances import account_balance
@@ -294,3 +296,25 @@ def test_scenario_planned_patch(client, db):
r = client.patch(f"/api/scenarios/{other['id']}/planned/{p['id']}", headers=H, json={ r = client.patch(f"/api/scenarios/{other['id']}/planned/{p['id']}", headers=H, json={
"name": "x", "amount": "1.00", "due": "2026-08-15"}) "name": "x", "amount": "1.00", "due": "2026-08-15"})
assert r.status_code == 404 assert r.status_code == 404
@pytest.mark.parametrize("name", ["System-Ist", "system-ist", " System-Ist ", "SYSTEM-IST"])
def test_szenario_name_system_ist_ist_reserviert_post(client, name):
r = client.post("/api/scenarios", json={"name": name}, headers=H)
assert r.status_code == 422
assert "reserviert" in r.json()["detail"]
def test_szenario_name_system_ist_ist_reserviert_patch(client):
sid = client.post("/api/scenarios", json={"name": "Normal"}, headers=H).json()["id"]
r = client.patch(f"/api/scenarios/{sid}", json={"name": "System-Ist"}, headers=H)
assert r.status_code == 422
assert "reserviert" in r.json()["detail"]
# Andere Felder ohne Namensänderung bleiben erlaubt.
r = client.patch(f"/api/scenarios/{sid}", json={"description": "x"}, headers=H)
assert r.status_code == 200
def test_szenario_normale_namen_weiter_erlaubt(client):
assert client.post("/api/scenarios", json={"name": "Ist-Zustand plus"},
headers=H).status_code == 201
+21
View File
@@ -483,3 +483,24 @@ def test_grafana_links_fallback_to_host_port_when_unset(client, monkeypatch):
assert "fb.example.de" not in r.text assert "fb.example.de" not in r.text
finally: finally:
get_settings.cache_clear() get_settings.cache_clear()
def test_szenarien_seite_hat_loeschen_button(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
sc = Scenario(name="Wegwerf", description="", include_recurring=True,
include_planned=True)
db.add(sc)
db.commit()
r = client.get("/szenarien")
assert f'hx-delete="/api/scenarios/{sc.id}"' in r.text
assert 'Szenario „Wegwerf" mit allen Einträgen wirklich löschen?' in r.text
def test_szenario_loeschen_roundtrip_gui(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
sc = Scenario(name="Wegwerf-Roundtrip", description="", include_recurring=True,
include_planned=True)
db.add(sc)
db.commit()
assert client.delete(f"/api/scenarios/{sc.id}", headers=H).status_code == 204
assert "Wegwerf-Roundtrip" not in client.get("/szenarien").text