release: publish saldo 0.1.0

This commit is contained in:
2026-04-21 21:17:36 +02:00
commit 6f5e704739
95 changed files with 9196 additions and 0 deletions
+174
View File
@@ -0,0 +1,174 @@
from __future__ import annotations
import logging
import secrets
from pathlib import Path
from flask import Flask, g, redirect, request, session, url_for
from flask_login import current_user
from .config import Config
from .extensions import db, login_manager, migrate
from .models import NotificationPreference, User
from .seed import seed_data
from .utils.users import sync_user_participants
from .services.allocation_service import AllocationSuggestionService
from .services.comparison_service import ComparisonService
from .services.month_service import MonthService
from .services.notification_service import NotificationService
from .services.push_service import PushService
from .services.share_service import ShareCalculationService
from .utils.formatting import currency
def create_app(config_class: type[Config] = Config) -> Flask:
app = Flask(__name__, instance_relative_config=True)
app.config.from_object(config_class)
app.config.setdefault("AVATAR_UPLOAD_DIR", Path(app.config["DATA_DIR"]) / "avatars")
app.config.setdefault("MAX_CONTENT_LENGTH", 5 * 1024 * 1024)
Path(app.config["DATA_DIR"]).mkdir(parents=True, exist_ok=True)
Path(app.config["AVATAR_UPLOAD_DIR"]).mkdir(parents=True, exist_ok=True)
db.init_app(app)
migrate.init_app(app, db)
login_manager.init_app(app)
app.jinja_env.filters["currency"] = currency
allocation_service = AllocationSuggestionService(
app.config["ALLOCATION_TARGET_RULES"],
app.config["DEFAULT_PERSONAL_SPLIT_DESI_PCT"],
)
comparison_service = ComparisonService()
share_service = ShareCalculationService()
push_service = PushService(
app.config["VAPID_PUBLIC_KEY"],
app.config["VAPID_PRIVATE_KEY"],
app.config["VAPID_CLAIMS"],
)
month_service = MonthService(allocation_service, comparison_service, share_service)
notification_service = NotificationService(
month_service, push_service, app.config["STRONG_INCOME_CHANGE_THRESHOLD"]
)
app.extensions["saldo.month_service"] = month_service
app.extensions["saldo.allocation_service"] = allocation_service
app.extensions["saldo.notification_service"] = notification_service
app.extensions["saldo.push_service"] = push_service
app.extensions["saldo.share_service"] = share_service
from .admin.routes import admin_bp
from .auth.routes import auth_bp
from .main.routes import main_bp
from .months.routes import months_bp
from .planning.routes import planning_bp
app.register_blueprint(auth_bp)
app.register_blueprint(main_bp)
app.register_blueprint(planning_bp)
app.register_blueprint(months_bp)
app.register_blueprint(admin_bp)
register_cli(app)
register_context(app)
register_hooks(app)
configure_logging(app)
return app
def register_context(app: Flask) -> None:
@app.context_processor
def inject_globals():
if "csrf_token" not in session:
session["csrf_token"] = secrets.token_urlsafe(32)
return {
"app_name": app.config["APP_NAME"],
"vapid_public_key": app.config["VAPID_PUBLIC_KEY"],
"csrf_token": session["csrf_token"],
}
def register_hooks(app: Flask) -> None:
@app.before_request
def ensure_current_month():
allowed_endpoints = {"auth.setup", "static", "main.health"}
if User.query.count() == 0 and request.endpoint not in allowed_endpoints:
return redirect(url_for("auth.setup"))
if request.method == "POST" and app.config.get("CSRF_ENABLED") and not app.config.get("TESTING"):
sent_token = request.headers.get("X-CSRF-Token") or request.form.get("csrf_token")
if request.endpoint != "planning.subscribe_push" and sent_token != session.get("csrf_token"):
return ("CSRF validation failed", 400)
if current_user.is_authenticated:
if sync_user_participants():
db.session.commit()
g.current_month = app.extensions["saldo.month_service"].ensure_month()
def register_cli(app: Flask) -> None:
import click
from .extensions import db
from .models import NotificationPreference, User
@app.cli.command("create-admin")
@click.option("--username", required=True)
@click.option("--email", required=True)
@click.option("--password", required=True)
@click.option("--display-name", default=None)
def create_admin(username: str, email: str, password: str, display_name: str | None):
user = User.query.filter_by(username=username).first()
if user is None:
user = User(
username=username,
display_name=display_name or username,
email=email,
role="admin",
)
db.session.add(user)
elif not user.display_name:
user.display_name = display_name or username
user.set_password(password)
user.is_active = True
pref = user.notification_preference or NotificationPreference(user=user)
db.session.add(pref)
db.session.commit()
click.echo(f"Admin {username} bereit.")
@app.cli.command("bootstrap-admin")
def bootstrap_admin():
username = app.config["ADMIN_BOOTSTRAP_USERNAME"]
password = app.config["ADMIN_BOOTSTRAP_PASSWORD"]
user = User.query.filter_by(username=username).first()
if user:
click.echo("Bootstrap-Admin existiert bereits.")
return
if not password:
click.echo("Kein Bootstrap-Passwort gesetzt. Bitte Setup-Seite oder create-admin nutzen.")
return
user = User(
username=username,
display_name=username,
email=app.config["ADMIN_BOOTSTRAP_EMAIL"],
role="admin",
)
user.set_password(password)
db.session.add(user)
db.session.flush()
db.session.add(NotificationPreference(user_id=user.id))
db.session.commit()
click.echo(f"Bootstrap-Admin {username} angelegt.")
@app.cli.command("seed")
def seed():
seed_data()
click.echo("Basisdaten angelegt.")
@app.cli.command("run-reminders")
def run_reminders():
count = app.extensions["saldo.notification_service"].run_monthly_checks()
click.echo(f"{count} Erinnerungen geprüft.")
def configure_logging(app: Flask) -> None:
logging.basicConfig(level=logging.INFO)
app.logger.setLevel(logging.INFO)