FireTracker / main.py
smlflg's picture
Initial public upload from Projekte/FireTracker
f91bd05 verified
Raw History Blame Contribute Delete
11.8 kB
from fastapi import FastAPI, Request, Depends, Form, HTTPException, UploadFile, File
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from pathlib import Path
from datetime import date
from database import init_db, get_db
from models import UserProfile, Asset, Snapshot
from schemas import AssetCreate, AssetUpdate, SnapshotCreate
from fire_math import calculate_fire, projection_timeline, calculate_scenario
from csv_import import parse_bank_csv
app = FastAPI(title="FireTracker")
templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
@app.on_event("startup")
def startup():
init_db()
def get_or_create_profile(db: Session) -> UserProfile:
"""Get the single user profile, create if not exists."""
profile = db.query(UserProfile).first()
if not profile:
profile = UserProfile(name="Samuel")
db.add(profile)
db.commit()
db.refresh(profile)
return profile
# === PAGES ===
@app.get("/", response_class=HTMLResponse)
def dashboard(request: Request, db: Session = Depends(get_db)):
profile = get_or_create_profile(db)
if profile.monthly_income == 0:
return RedirectResponse("/setup", status_code=303)
assets = db.query(Asset).all()
total_portfolio = sum(a.current_value for a in assets)
fire = calculate_fire(
monthly_income=profile.monthly_income,
monthly_expenses=profile.monthly_expenses,
total_portfolio=total_portfolio,
birth_year=profile.birth_year,
swr=profile.swr,
expected_return=profile.expected_return,
inflation=profile.inflation,
target_retirement_age=profile.target_retirement_age,
)
timeline = projection_timeline(
total_portfolio=total_portfolio,
monthly_savings=fire.monthly_savings,
annual_return=fire.real_return,
years=30,
)
# Group assets by type
asset_groups = {}
for a in assets:
asset_groups.setdefault(a.asset_type, []).append(a)
return templates.TemplateResponse("dashboard.html", {
"request": request,
"profile": profile,
"fire": fire,
"assets": assets,
"asset_groups": asset_groups,
"total_portfolio": total_portfolio,
"timeline": timeline,
"page": "dashboard",
})
@app.get("/assets", response_class=HTMLResponse)
def assets_page(request: Request, db: Session = Depends(get_db)):
assets = db.query(Asset).all()
return templates.TemplateResponse("assets.html", {
"request": request,
"assets": assets,
"page": "assets",
"asset_types": ["depot", "immobilie", "cash", "crypto", "pension", "other"],
})
@app.get("/settings", response_class=HTMLResponse)
def settings_page(request: Request, db: Session = Depends(get_db)):
profile = get_or_create_profile(db)
return templates.TemplateResponse("settings.html", {
"request": request,
"profile": profile,
"page": "settings",
})
@app.get("/setup", response_class=HTMLResponse)
def setup_page(request: Request, db: Session = Depends(get_db)):
profile = get_or_create_profile(db)
return templates.TemplateResponse("setup.html", {
"request": request,
"profile": profile,
"page": "setup",
})
# === HTMX API ENDPOINTS ===
@app.post("/api/profile", response_class=HTMLResponse)
def update_profile(
request: Request,
monthly_income: float = Form(...),
monthly_expenses: float = Form(...),
birth_year: int = Form(...),
target_retirement_age: int = Form(67),
swr: float = Form(0.04),
expected_return: float = Form(0.07),
inflation: float = Form(0.02),
db: Session = Depends(get_db),
):
profile = get_or_create_profile(db)
profile.monthly_income = monthly_income
profile.monthly_expenses = monthly_expenses
profile.birth_year = birth_year
profile.target_retirement_age = target_retirement_age
profile.swr = swr
profile.expected_return = expected_return
profile.inflation = inflation
db.commit()
return RedirectResponse("/", status_code=303)
@app.post("/api/setup", response_class=HTMLResponse)
def complete_setup(
request: Request,
monthly_income: float = Form(...),
monthly_expenses: float = Form(...),
birth_year: int = Form(...),
db: Session = Depends(get_db),
):
"""Quick setup — just the 3 essential numbers."""
profile = get_or_create_profile(db)
profile.monthly_income = monthly_income
profile.monthly_expenses = monthly_expenses
profile.birth_year = birth_year
db.commit()
return RedirectResponse("/", status_code=303)
@app.post("/api/assets")
def create_asset(
request: Request,
name: str = Form(...),
asset_type: str = Form(...),
current_value: float = Form(0),
monthly_contribution: float = Form(0),
notes: str = Form(""),
db: Session = Depends(get_db),
):
asset = Asset(
name=name,
asset_type=asset_type,
current_value=current_value,
monthly_contribution=monthly_contribution,
notes=notes,
)
db.add(asset)
db.commit()
# Also create initial snapshot
snapshot = Snapshot(asset_id=asset.id, value=current_value, date=date.today())
db.add(snapshot)
db.commit()
return RedirectResponse("/assets", status_code=303)
@app.put("/api/assets/{asset_id}", response_class=HTMLResponse)
def update_asset(
asset_id: int,
request: Request,
name: str = Form(...),
asset_type: str = Form(...),
current_value: float = Form(0),
monthly_contribution: float = Form(0),
notes: str = Form(""),
db: Session = Depends(get_db),
):
asset = db.get(Asset, asset_id)
if not asset:
raise HTTPException(404)
old_value = asset.current_value
asset.name = name
asset.asset_type = asset_type
asset.current_value = current_value
asset.monthly_contribution = monthly_contribution
asset.notes = notes
db.commit()
# Create snapshot if value changed
if current_value != old_value:
snapshot = Snapshot(asset_id=asset.id, value=current_value, date=date.today())
db.add(snapshot)
db.commit()
# Return updated asset card partial for htmx swap
assets = db.query(Asset).all()
return templates.TemplateResponse("partials/asset_list.html", {
"request": request,
"assets": assets,
"asset_types": ["depot", "immobilie", "cash", "crypto", "pension", "other"],
})
@app.delete("/api/assets/{asset_id}", response_class=HTMLResponse)
def delete_asset(
asset_id: int,
request: Request,
db: Session = Depends(get_db),
):
asset = db.get(Asset, asset_id)
if not asset:
raise HTTPException(404)
db.delete(asset)
db.commit()
assets = db.query(Asset).all()
return templates.TemplateResponse("partials/asset_list.html", {
"request": request,
"assets": assets,
"asset_types": ["depot", "immobilie", "cash", "crypto", "pension", "other"],
})
@app.post("/api/snapshots")
def add_snapshot(
asset_id: int = Form(...),
value: float = Form(...),
snapshot_date: str = Form(None),
db: Session = Depends(get_db),
):
asset = db.get(Asset, asset_id)
if not asset:
raise HTTPException(404)
d = date.fromisoformat(snapshot_date) if snapshot_date else date.today()
snapshot = Snapshot(asset_id=asset_id, value=value, date=d)
db.add(snapshot)
asset.current_value = value
db.commit()
return RedirectResponse("/assets", status_code=303)
# === JSON API (for Chart.js) ===
@app.get("/api/timeline")
def get_timeline(db: Session = Depends(get_db)):
profile = get_or_create_profile(db)
assets = db.query(Asset).all()
total = sum(a.current_value for a in assets)
fire = calculate_fire(
monthly_income=profile.monthly_income,
monthly_expenses=profile.monthly_expenses,
total_portfolio=total,
birth_year=profile.birth_year,
swr=profile.swr,
expected_return=profile.expected_return,
inflation=profile.inflation,
)
timeline = projection_timeline(total, fire.monthly_savings, fire.real_return, 30)
return {"timeline": timeline, "fi_number": fire.fi_number}
@app.get("/api/scenario")
def get_scenario(
extra_savings: float = 0,
adjusted_expenses: float = None,
db: Session = Depends(get_db),
):
profile = get_or_create_profile(db)
assets = db.query(Asset).all()
total = sum(a.current_value for a in assets)
base = calculate_fire(
monthly_income=profile.monthly_income,
monthly_expenses=profile.monthly_expenses,
total_portfolio=total,
birth_year=profile.birth_year,
swr=profile.swr,
expected_return=profile.expected_return,
inflation=profile.inflation,
)
scenario = calculate_scenario(
base=base,
monthly_income=profile.monthly_income,
monthly_expenses=profile.monthly_expenses,
total_portfolio=total,
birth_year=profile.birth_year,
extra_monthly_savings=extra_savings,
adjusted_expenses=adjusted_expenses,
swr=profile.swr,
expected_return=profile.expected_return,
inflation=profile.inflation,
)
return {
"base_years": base.years_to_fi,
"scenario_years": scenario.years_to_fi,
"base_fi_date": str(base.fi_date) if base.fi_date else None,
"scenario_fi_date": str(scenario.fi_date) if scenario.fi_date else None,
"diff_years": round(base.years_to_fi - scenario.years_to_fi, 1) if base.years_to_fi and scenario.years_to_fi else None,
}
@app.get("/import", response_class=HTMLResponse)
def import_page(request: Request, db: Session = Depends(get_db)):
return templates.TemplateResponse("import.html", {
"request": request,
"page": "import",
})
@app.post("/api/import/csv")
async def import_csv(
request: Request,
file: UploadFile = File(...),
bank_format: str = Form("auto"),
db: Session = Depends(get_db),
):
"""Parse bank CSV and return categorized transactions."""
content = await file.read()
result = parse_bank_csv(content)
return templates.TemplateResponse("import.html", {
"request": request,
"page": "import",
"transactions": result["transactions"],
"total_income": round(result["total_income"], 2),
"total_expenses": round(result["total_expenses"], 2),
"net": round(result["net"], 2),
"count": result["count"],
"monthly_estimate": round(result["monthly_estimate"], 2),
})
@app.get("/api/export")
def export_data(db: Session = Depends(get_db)):
"""Export all data as JSON for backup/portability."""
profile = get_or_create_profile(db)
assets = db.query(Asset).all()
snapshots = db.query(Snapshot).all()
return {
"profile": {
"monthly_income": profile.monthly_income,
"monthly_expenses": profile.monthly_expenses,
"birth_year": profile.birth_year,
"target_retirement_age": profile.target_retirement_age,
"swr": profile.swr,
"expected_return": profile.expected_return,
"inflation": profile.inflation,
},
"assets": [{"name": a.name, "type": a.asset_type, "value": a.current_value, "contribution": a.monthly_contribution, "notes": a.notes} for a in assets],
"snapshots": [{"asset_id": s.asset_id, "date": str(s.date), "value": s.value} for s in snapshots],
}