feat: Präfix System- für Tool-Serien reserviert, Kreditraten-Serie heißt System-Kreditraten (A12-T1)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.engine import Engine
|
from sqlalchemy.engine import Engine
|
||||||
|
|
||||||
|
from app.reserved import SYSTEM_PREFIX
|
||||||
|
|
||||||
# Serienname der Ist-Kurve im Grafana-Panel „Szenario-Vergleich" (v_projection).
|
# Serienname der Ist-Kurve im Grafana-Panel „Szenario-Vergleich" (v_projection).
|
||||||
# Für Szenarien reserviert (routers/scenarios.py lehnt ihn ab), damit kein
|
# Für Szenarien reserviert über den System-Präfix, siehe app/reserved.py.
|
||||||
# Szenario mit der Ist-Serie verschmelzen kann (Ausbaustufe 11).
|
RESERVED_SCENARIO_NAME = f"{SYSTEM_PREFIX}Ist"
|
||||||
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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -6,6 +6,7 @@ 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, CategoryRule
|
from app.models.tables import Category, CategoryRule
|
||||||
|
from app.reserved import check_reserved_name
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["categories"],
|
router = APIRouter(prefix="/api", tags=["categories"],
|
||||||
dependencies=[Depends(require_auth)])
|
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)
|
@router.post("/categories", response_model=CategoryOut, status_code=201)
|
||||||
def create_category(data: CategoryIn, session: Session = Depends(get_session)):
|
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():
|
if session.execute(select(Category).where(Category.name == data.name)).scalar():
|
||||||
raise HTTPException(409, "Kategorie existiert bereits")
|
raise HTTPException(409, "Kategorie existiert bereits")
|
||||||
cat = Category(**data.model_dump())
|
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)
|
@router.patch("/categories/{category_id}", response_model=CategoryOut)
|
||||||
def patch_category(category_id: int, data: CategoryIn,
|
def patch_category(category_id: int, data: CategoryIn,
|
||||||
session: Session = Depends(get_session)):
|
session: Session = Depends(get_session)):
|
||||||
|
check_reserved_name(data.name)
|
||||||
cat = session.get(Category, category_id)
|
cat = session.get(Category, category_id)
|
||||||
if cat is None:
|
if cat is None:
|
||||||
raise HTTPException(404, "Kategorie nicht gefunden")
|
raise HTTPException(404, "Kategorie nicht gefunden")
|
||||||
|
|||||||
@@ -13,7 +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.reserved import check_reserved_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"],
|
||||||
@@ -86,14 +86,6 @@ 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)
|
||||||
@@ -102,7 +94,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)
|
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())
|
||||||
@@ -117,7 +109,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"))
|
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()
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
Monatssummen je Kategorie aus der reinen Planung (ohne Szenario-Modifikatoren):
|
Monatssummen je Kategorie aus der reinen Planung (ohne Szenario-Modifikatoren):
|
||||||
wiederkehrende Posten, Einmalposten und die Raten ALLER Kredite (Kategorie
|
wiederkehrende Posten, Einmalposten und die Raten ALLER Kredite (Kategorie
|
||||||
„Kreditraten", Auszahlung zählt nicht). Einnahmen und Ausgaben mit Vorzeichen,
|
„System-Kreditraten", Auszahlung zählt nicht). Einnahmen und Ausgaben mit
|
||||||
Semantik wie v_monthly_by_category. Kreditraten brauchen die Annuitätenformel
|
Vorzeichen, Semantik wie v_monthly_by_category. Kreditraten brauchen die
|
||||||
(engine/loans.py) - daher Python + Tabelle statt SQL-View.
|
Annuitätenformel (engine/loans.py) - daher Python + Tabelle statt SQL-View.
|
||||||
"""
|
"""
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
@@ -19,9 +19,10 @@ from app.engine.loans import loan_schedule
|
|||||||
from app.engine.recurrence import occurrences
|
from app.engine.recurrence import occurrences
|
||||||
from app.models.tables import (Category, Loan, PlannedItem, PlanningMonthly,
|
from app.models.tables import (Category, Loan, PlannedItem, PlanningMonthly,
|
||||||
RecurringItem)
|
RecurringItem)
|
||||||
|
from app.reserved import SYSTEM_PREFIX
|
||||||
|
|
||||||
UNKATEGORISIERT = "unkategorisiert"
|
UNKATEGORISIERT = "unkategorisiert"
|
||||||
KREDITRATEN = "Kreditraten"
|
KREDITRATEN = f"{SYSTEM_PREFIX}Kreditraten"
|
||||||
PLANNING_CHART_LOCK = 110_002
|
PLANNING_CHART_LOCK = 110_002
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
Reference in New Issue
Block a user