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>
187 lines
6.8 KiB
Python
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],
|
|
)
|