Files
bin/finance/app/routers/scenarios.py
wlfb cf9282c716 fix: Final-Review-Findings (GUI-Login, FK-Kaskaden, Validierung, Duplikat-Recheck, Views)
F1: GUI-Login-Hash gegen $-Korruption gehaertet. .env-Werte einfach-gequotet
    geschrieben + Sanity-Check nach Sourcing. Zusaetzlich systemd-Ebene:
    podman escapt $->$$ in Environment=, systemd 252 kollabiert das nicht ->
    sed-Post-Processing der Unit-Dateien. Live-.env: GUI-Passwort rotiert.
F2: Explizite Dependent-Deletes gegen FK-500 (delete_scenario/loan), 409 bei
    bestaetigtem Import-Delete. Test-Engine erzwingt PRAGMA foreign_keys=ON.
F3: Enum/Range-Validierung (Literal + Field ge/le) fuer Recurring, Loan,
    Modifier inkl. PATCH-Pfade.
F4: Confirm nur fuer draft-Statements (sonst 409) + Dedup-Re-Check gegen
    bestaetigte Buchungen vor dem Promoten.
F5: v_balance_history/v_balance_total addieren pro Konto den opening_balance
    des fruehesten bestaetigten Statements (approved deviation vom Plan-SQL).
F6: patch_scenario Namenskollision -> 409; create_transaction unbekanntes
    Konto -> 404 (statt 500).
F7: Generierte systemd-Unit-Dateien chmod 600 (enthalten Env-Secrets).
F8: Globaler htmx:responseError-Handler in base.html.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:11:31 +02:00

187 lines
6.8 KiB
Python

from datetime import date
from decimal import Decimal
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.auth import require_auth
from app.config import get_settings
from app.db import get_session
from app.models.tables import (Loan, ProjectionPoint, ProjectionResult,
Scenario, ScenarioLoan, ScenarioModifier)
from app.services.projection_service import run_projection
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"],
dependencies=[Depends(require_auth)])
class ScenarioIn(BaseModel):
name: str
description: str = ""
include_recurring: bool = True
include_planned: bool = True
class ScenarioOut(ScenarioIn):
model_config = ConfigDict(from_attributes=True)
id: int
class ScenarioPatch(BaseModel):
name: str | None = None
description: str | None = None
include_recurring: bool | None = None
include_planned: bool | None = None
class ModifierIn(BaseModel):
target_type: Literal["category", "recurring"]
target_id: int
kind: Literal["percent", "absolute", "remove"]
value: Decimal = Decimal("0")
class ModifierOut(ModifierIn):
model_config = ConfigDict(from_attributes=True)
id: int
scenario_id: int
class SeriesPointOut(BaseModel):
day: date
balance: Decimal
class ProjectionOut(BaseModel):
low_point_date: date
low_point_balance: Decimal
below_zero_date: date | None
below_threshold_date: date | None
series: list[SeriesPointOut]
def _get_scenario(session: Session, scenario_id: int) -> Scenario:
scenario = session.get(Scenario, scenario_id)
if scenario is None:
raise HTTPException(404, "Szenario nicht gefunden")
return scenario
@router.get("", response_model=list[ScenarioOut])
def list_scenarios(session: Session = Depends(get_session)):
return [ScenarioOut.model_validate(s)
for s in session.execute(select(Scenario)).scalars()]
@router.post("", response_model=ScenarioOut, status_code=201)
def create_scenario(data: ScenarioIn, session: Session = Depends(get_session)):
if session.execute(select(Scenario).where(Scenario.name == data.name)).scalar():
raise HTTPException(409, "Szenario existiert bereits")
scenario = Scenario(**data.model_dump())
session.add(scenario)
session.commit()
session.refresh(scenario)
return ScenarioOut.model_validate(scenario)
@router.patch("/{scenario_id}", response_model=ScenarioOut)
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)
if "name" in fields and fields["name"] != scenario.name:
clash = session.execute(
select(Scenario).where(Scenario.name == fields["name"])).scalar()
if clash is not None:
raise HTTPException(409, "Szenario existiert bereits")
for key, value in fields.items():
setattr(scenario, key, value)
session.commit()
session.refresh(scenario)
return ScenarioOut.model_validate(scenario)
@router.delete("/{scenario_id}", status_code=204)
def delete_scenario(scenario_id: int, session: Session = Depends(get_session)):
scenario = _get_scenario(session, scenario_id)
# No DB-level cascades exist, so remove dependent rows explicitly to avoid
# foreign-key violations (would surface as a 500 on Postgres).
session.execute(delete(ProjectionPoint).where(
ProjectionPoint.scenario_id == scenario_id))
session.execute(delete(ProjectionResult).where(
ProjectionResult.scenario_id == scenario_id))
session.execute(delete(ScenarioLoan).where(
ScenarioLoan.scenario_id == scenario_id))
session.execute(delete(ScenarioModifier).where(
ScenarioModifier.scenario_id == scenario_id))
session.delete(scenario)
session.commit()
@router.post("/{scenario_id}/loans/{loan_id}", status_code=204)
def add_scenario_loan(scenario_id: int, loan_id: int,
session: Session = Depends(get_session)):
_get_scenario(session, scenario_id)
if session.get(Loan, loan_id) is None:
raise HTTPException(404, "Kredit nicht gefunden")
if session.get(ScenarioLoan, (scenario_id, loan_id)) is None:
session.add(ScenarioLoan(scenario_id=scenario_id, loan_id=loan_id))
session.commit()
@router.delete("/{scenario_id}/loans/{loan_id}", status_code=204)
def remove_scenario_loan(scenario_id: int, loan_id: int,
session: Session = Depends(get_session)):
_get_scenario(session, scenario_id)
link = session.get(ScenarioLoan, (scenario_id, loan_id))
if link is not None:
session.delete(link)
session.commit()
@router.post("/{scenario_id}/modifiers", response_model=ModifierOut, status_code=201)
def add_modifier(scenario_id: int, data: ModifierIn,
session: Session = Depends(get_session)):
_get_scenario(session, scenario_id)
modifier = ScenarioModifier(scenario_id=scenario_id, **data.model_dump())
session.add(modifier)
session.commit()
session.refresh(modifier)
return ModifierOut.model_validate(modifier)
@router.delete("/{scenario_id}/modifiers/{mod_id}", status_code=204)
def delete_modifier(scenario_id: int, mod_id: int,
session: Session = Depends(get_session)):
_get_scenario(session, scenario_id)
modifier = session.get(ScenarioModifier, mod_id)
if modifier is None or modifier.scenario_id != scenario_id:
raise HTTPException(404, "Modifikator nicht gefunden")
session.delete(modifier)
session.commit()
@router.post("/{scenario_id}/project", response_model=ProjectionOut)
def project_scenario(scenario_id: int, horizon_days: int | None = None,
start_date: date | None = None,
session: Session = Depends(get_session)):
scenario = _get_scenario(session, scenario_id)
settings = get_settings()
resolved_horizon = horizon_days if horizon_days is not None else settings.horizon_days
resolved_start = start_date if start_date is not None else date.today()
result = run_projection(session, scenario, resolved_horizon, resolved_start)
points = session.execute(
select(ProjectionPoint).where(ProjectionPoint.scenario_id == scenario_id)
.order_by(ProjectionPoint.day)
).scalars().all()
return ProjectionOut(
low_point_date=result.low_point_date,
low_point_balance=result.low_point_balance,
below_zero_date=result.below_zero_date,
below_threshold_date=result.below_threshold_date,
series=[SeriesPointOut(day=p.day, balance=p.balance) for p in points],
)