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)