175 lines
6.4 KiB
Python
175 lines
6.4 KiB
Python
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)
|