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:
@@ -1,6 +1,11 @@
|
||||
from sqlalchemy import text
|
||||
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
|
||||
# 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)
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.db import get_session
|
||||
from app.models.tables import (Loan, ProjectionPoint, ProjectionResult,
|
||||
Scenario, ScenarioLoan, ScenarioModifier,
|
||||
ScenarioPlannedItem)
|
||||
from app.models.views import RESERVED_SCENARIO_NAME
|
||||
from app.services.projection_service import run_projection
|
||||
|
||||
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"],
|
||||
@@ -85,6 +86,14 @@ def _get_scenario(session: Session, scenario_id: int) -> 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])
|
||||
def list_scenarios(session: Session = Depends(get_session)):
|
||||
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)
|
||||
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():
|
||||
raise HTTPException(409, "Szenario existiert bereits")
|
||||
scenario = Scenario(**data.model_dump())
|
||||
@@ -107,6 +117,7 @@ def patch_scenario(scenario_id: int, data: ScenarioPatch,
|
||||
session: Session = Depends(get_session)):
|
||||
scenario = _get_scenario(session, scenario_id)
|
||||
fields = data.model_dump(exclude_unset=True)
|
||||
_check_reserved_name(fields.get("name"))
|
||||
if "name" in fields and fields["name"] != scenario.name:
|
||||
clash = session.execute(
|
||||
select(Scenario).where(Scenario.name == fields["name"])).scalar()
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
· Wiederkehrende Posten: {{ 'ja' if sc.include_recurring else 'nein' }}
|
||||
· Einmalposten: {{ 'ja' if sc.include_planned else 'nein' }}
|
||||
<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>
|
||||
<div id="sc-edit-{{ sc.id }}" hidden>
|
||||
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}" hx-swap="none"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.tables import Account, Transaction
|
||||
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={
|
||||
"name": "x", "amount": "1.00", "due": "2026-08-15"})
|
||||
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
|
||||
|
||||
@@ -483,3 +483,24 @@ def test_grafana_links_fallback_to_host_port_when_unset(client, monkeypatch):
|
||||
assert "fb.example.de" not in r.text
|
||||
finally:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user