from datetime import date from decimal import Decimal from typing import Literal from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, ConfigDict, field_validator, model_validator 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, ScenarioPlannedItem) 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 @field_validator("description") @classmethod def _leere_beschreibung(cls, v): return "" if v is None else v class ModifierIn(BaseModel): target_type: Literal["category", "recurring"] target_id: int kind: Literal["percent", "absolute", "remove", "ende"] value: Decimal = Decimal("0") end_date: date | None = None @model_validator(mode="after") def _ende_braucht_datum(self): if self.kind == "ende" and self.end_date is None: raise ValueError("Art »Ende« erfordert ein Datum") if self.kind != "ende": self.end_date = None return self 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.execute(delete(ScenarioPlannedItem).where( ScenarioPlannedItem.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.patch("/{scenario_id}/modifiers/{mod_id}", response_model=ModifierOut) def patch_modifier(scenario_id: int, mod_id: int, data: ModifierIn, session: Session = Depends(get_session)): # Full-Body-Update: das Edit-Formular sendet immer alle Felder des Typs. _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") for key, value in data.model_dump().items(): setattr(modifier, key, value) session.commit() session.refresh(modifier) return ModifierOut.model_validate(modifier) class ScenarioPlannedIn(BaseModel): name: str amount: Decimal due: date class ScenarioPlannedOut(ScenarioPlannedIn): model_config = ConfigDict(from_attributes=True) id: int scenario_id: int @router.get("/{scenario_id}/planned", response_model=list[ScenarioPlannedOut]) def list_scenario_planned(scenario_id: int, session: Session = Depends(get_session)): _get_scenario(session, scenario_id) return [ScenarioPlannedOut.model_validate(p) for p in session.execute( select(ScenarioPlannedItem) .where(ScenarioPlannedItem.scenario_id == scenario_id) .order_by(ScenarioPlannedItem.due)).scalars()] @router.post("/{scenario_id}/planned", response_model=ScenarioPlannedOut, status_code=201) def add_scenario_planned(scenario_id: int, data: ScenarioPlannedIn, session: Session = Depends(get_session)): _get_scenario(session, scenario_id) item = ScenarioPlannedItem(scenario_id=scenario_id, **data.model_dump()) session.add(item) session.commit() session.refresh(item) return ScenarioPlannedOut.model_validate(item) @router.delete("/{scenario_id}/planned/{item_id}", status_code=204) def delete_scenario_planned(scenario_id: int, item_id: int, session: Session = Depends(get_session)): _get_scenario(session, scenario_id) item = session.get(ScenarioPlannedItem, item_id) if item is None or item.scenario_id != scenario_id: raise HTTPException(404, "Einmalzahlung nicht gefunden") session.delete(item) session.commit() @router.patch("/{scenario_id}/planned/{item_id}", response_model=ScenarioPlannedOut) def patch_scenario_planned(scenario_id: int, item_id: int, data: ScenarioPlannedIn, session: Session = Depends(get_session)): # Full-Body-Update, Muster patch_modifier. _get_scenario(session, scenario_id) item = session.get(ScenarioPlannedItem, item_id) if item is None or item.scenario_id != scenario_id: raise HTTPException(404, "Einmalzahlung nicht gefunden") for key, value in data.model_dump().items(): setattr(item, key, value) session.commit() session.refresh(item) return ScenarioPlannedOut.model_validate(item) @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], )