diff --git a/finance/app/models/views.py b/finance/app/models/views.py index e537fc4..07c6f7a 100644 --- a/finance/app/models/views.py +++ b/finance/app/models/views.py @@ -1,10 +1,11 @@ from sqlalchemy import text from sqlalchemy.engine import Engine +from app.reserved import SYSTEM_PREFIX + # 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" +# Für Szenarien reserviert über den System-Präfix, siehe app/reserved.py. +RESERVED_SCENARIO_NAME = f"{SYSTEM_PREFIX}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 diff --git a/finance/app/reserved.py b/finance/app/reserved.py new file mode 100644 index 0000000..8f857ae --- /dev/null +++ b/finance/app/reserved.py @@ -0,0 +1,24 @@ +"""Reservierte Namen (Ausbaustufe 12). + +Alles, was mit SYSTEM_PREFIX beginnt, gehört dem Tool: synthetische Serien +wie „System-Ist" (v_projection) oder „System-Kreditraten" (Planungs-Panel). +Kategorien und Szenarien dürfen solche Namen nicht tragen, sonst verschmelzen +sie in Grafana mit den Tool-Serien. +""" +from fastapi import HTTPException + +SYSTEM_PREFIX = "System-" +RESERVED_MESSAGE = (f"Namen, die mit »{SYSTEM_PREFIX}« beginnen, sind für " + "vom Tool erzeugte Einträge reserviert") + + +def is_reserved_name(name: str) -> bool: + """True, wenn name (ohne Randleerzeichen, Groß/klein egal) mit dem + System-Präfix beginnt.""" + return name.strip().casefold().startswith(SYSTEM_PREFIX.casefold()) + + +def check_reserved_name(name: str | None) -> None: + """None (Feld nicht gesetzt) -> keine Prüfung; reserviert -> 422.""" + if name is not None and is_reserved_name(name): + raise HTTPException(422, RESERVED_MESSAGE) diff --git a/finance/app/routers/categories.py b/finance/app/routers/categories.py index 4bd79d0..b0beeaa 100644 --- a/finance/app/routers/categories.py +++ b/finance/app/routers/categories.py @@ -6,6 +6,7 @@ from sqlalchemy.orm import Session from app.auth import require_auth from app.db import get_session from app.models.tables import Category, CategoryRule +from app.reserved import check_reserved_name router = APIRouter(prefix="/api", tags=["categories"], dependencies=[Depends(require_auth)]) @@ -39,6 +40,7 @@ def list_categories(session: Session = Depends(get_session)): @router.post("/categories", response_model=CategoryOut, status_code=201) def create_category(data: CategoryIn, session: Session = Depends(get_session)): + check_reserved_name(data.name) if session.execute(select(Category).where(Category.name == data.name)).scalar(): raise HTTPException(409, "Kategorie existiert bereits") cat = Category(**data.model_dump()) @@ -51,6 +53,7 @@ def create_category(data: CategoryIn, session: Session = Depends(get_session)): @router.patch("/categories/{category_id}", response_model=CategoryOut) def patch_category(category_id: int, data: CategoryIn, session: Session = Depends(get_session)): + check_reserved_name(data.name) cat = session.get(Category, category_id) if cat is None: raise HTTPException(404, "Kategorie nicht gefunden") diff --git a/finance/app/routers/scenarios.py b/finance/app/routers/scenarios.py index 325859c..51a2c7a 100644 --- a/finance/app/routers/scenarios.py +++ b/finance/app/routers/scenarios.py @@ -13,7 +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.reserved import check_reserved_name from app.services.projection_service import run_projection router = APIRouter(prefix="/api/scenarios", tags=["scenarios"], @@ -86,14 +86,6 @@ 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) @@ -102,7 +94,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) + 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()) @@ -117,7 +109,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")) + 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() diff --git a/finance/app/services/planning_chart.py b/finance/app/services/planning_chart.py index f743995..15cc478 100644 --- a/finance/app/services/planning_chart.py +++ b/finance/app/services/planning_chart.py @@ -2,9 +2,9 @@ Monatssummen je Kategorie aus der reinen Planung (ohne Szenario-Modifikatoren): wiederkehrende Posten, Einmalposten und die Raten ALLER Kredite (Kategorie -„Kreditraten", Auszahlung zählt nicht). Einnahmen und Ausgaben mit Vorzeichen, -Semantik wie v_monthly_by_category. Kreditraten brauchen die Annuitätenformel -(engine/loans.py) - daher Python + Tabelle statt SQL-View. +„System-Kreditraten", Auszahlung zählt nicht). Einnahmen und Ausgaben mit +Vorzeichen, Semantik wie v_monthly_by_category. Kreditraten brauchen die +Annuitätenformel (engine/loans.py) - daher Python + Tabelle statt SQL-View. """ from collections import defaultdict from datetime import date, timedelta @@ -19,9 +19,10 @@ from app.engine.loans import loan_schedule from app.engine.recurrence import occurrences from app.models.tables import (Category, Loan, PlannedItem, PlanningMonthly, RecurringItem) +from app.reserved import SYSTEM_PREFIX UNKATEGORISIERT = "unkategorisiert" -KREDITRATEN = "Kreditraten" +KREDITRATEN = f"{SYSTEM_PREFIX}Kreditraten" PLANNING_CHART_LOCK = 110_002 diff --git a/finance/tests/test_reserved.py b/finance/tests/test_reserved.py new file mode 100644 index 0000000..4a3e184 --- /dev/null +++ b/finance/tests/test_reserved.py @@ -0,0 +1,50 @@ +import pytest + +from app.models.views import RESERVED_SCENARIO_NAME +from app.reserved import SYSTEM_PREFIX, is_reserved_name +from app.services.planning_chart import KREDITRATEN + +H = {"Authorization": "Bearer test-key"} + + +def test_systemnamen_leiten_sich_vom_praefix_ab(): + assert SYSTEM_PREFIX == "System-" + assert RESERVED_SCENARIO_NAME == "System-Ist" + assert KREDITRATEN == "System-Kreditraten" + + +@pytest.mark.parametrize("name,expected", [ + ("System-X", True), ("system-kreditraten", True), (" SYSTEM-Ist ", True), + ("Kreditraten", False), ("Systemkosten", False), ("Ist-System", False), + ("System", False), +]) +def test_is_reserved_name(name, expected): + assert is_reserved_name(name) is expected + + +@pytest.mark.parametrize("name", ["System-X", "system-kreditraten", " SYSTEM-Ist "]) +def test_kategorie_post_reserviert(client, name): + r = client.post("/api/categories", headers=H, json={"name": name}) + assert r.status_code == 422 + assert r.json()["detail"] == ("Namen, die mit »System-« beginnen, sind für " + "vom Tool erzeugte Einträge reserviert") + + +def test_kategorie_patch_reserviert(client): + cat = client.post("/api/categories", headers=H, json={"name": "Normal"}).json() + r = client.patch(f"/api/categories/{cat['id']}", headers=H, json={"name": "System-Test"}) + assert r.status_code == 422 + names = [c["name"] for c in client.get("/api/categories", headers=H).json()] + assert "Normal" in names and "System-Test" not in names + + +@pytest.mark.parametrize("name", ["Kreditraten", "Systemkosten", "Ist-System"]) +def test_kategorie_normale_namen_erlaubt(client, name): + assert client.post("/api/categories", headers=H, json={"name": name}).status_code == 201 + + +@pytest.mark.parametrize("name", ["System-Plan", "system-x"]) +def test_szenario_praefix_reserviert(client, name): + r = client.post("/api/scenarios", headers=H, json={"name": name}) + assert r.status_code == 422 + assert "reserviert" in r.json()["detail"]