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
+2
View File
@@ -0,0 +1,2 @@
from .routes import admin_bp
+291
View File
@@ -0,0 +1,291 @@
from __future__ import annotations
from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_login import login_required
from sqlalchemy import select
from app.extensions import db
from app.models import (
Account,
Category,
CostParticipant,
Entry,
EntryShareRule,
MonthlyEntryValue,
Month,
NotificationPreference,
User,
)
from app.seed import slugify
from app.utils.uploads import save_avatar_upload
from app.utils.decorators import admin_required
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
def _resolve_avatar_url(existing: str | None = None) -> str | None:
upload = request.files.get("avatar_file")
if upload and upload.filename:
try:
return save_avatar_upload(upload)
except ValueError as exc:
flash(str(exc), "danger")
return existing
avatar_url = request.form.get("avatar_url")
if avatar_url is not None:
avatar_url = avatar_url.strip()
return avatar_url or existing
return existing
@admin_bp.route("/")
@login_required
@admin_required
def index():
users = User.query.order_by(User.display_name.asc(), User.username.asc()).all()
participants = CostParticipant.query.order_by(CostParticipant.name.asc()).all()
accounts = db.session.scalars(select(Account).order_by(Account.sort_order.asc(), Account.name.asc())).all()
categories = db.session.scalars(
select(Category).order_by(Category.account_id.asc(), Category.sort_order.asc(), Category.name.asc())
).all()
entries = db.session.scalars(
select(Entry).order_by(Entry.category_id.asc(), Entry.sort_order.asc(), Entry.name.asc())
).all()
return render_template(
"admin/index.html",
users=users,
participants=participants,
accounts=accounts,
categories=categories,
entries=entries,
)
@admin_bp.route("/users", methods=["POST"])
@login_required
@admin_required
def create_user():
user = User(
username=request.form["username"].strip(),
display_name=request.form["display_name"].strip(),
email=request.form["email"].strip(),
avatar_url=_resolve_avatar_url(),
role=request.form.get("role", "editor"),
is_active=True,
)
user.set_password(request.form["password"])
db.session.add(user)
db.session.flush()
db.session.add(NotificationPreference(user_id=user.id))
db.session.commit()
flash("Benutzer angelegt.", "success")
return redirect(url_for("admin.index"))
@admin_bp.route("/users/<int:user_id>", methods=["POST"])
@login_required
@admin_required
def update_user(user_id: int):
user = User.query.get_or_404(user_id)
user.display_name = request.form["display_name"].strip()
user.email = request.form["email"].strip()
user.avatar_url = _resolve_avatar_url(user.avatar_url)
user.role = request.form.get("role", user.role)
user.is_active = request.form.get("is_active") == "on"
db.session.commit()
flash("Benutzer aktualisiert.", "success")
return redirect(url_for("admin.index"))
@admin_bp.route("/users/<int:user_id>/toggle", methods=["POST"])
@login_required
@admin_required
def toggle_user(user_id: int):
user = User.query.get_or_404(user_id)
user.is_active = not user.is_active
db.session.commit()
flash("Benutzerstatus aktualisiert.", "info")
return redirect(url_for("admin.index"))
@admin_bp.route("/participants", methods=["POST"])
@login_required
@admin_required
def create_participant():
participant = CostParticipant(
name=request.form["name"].strip(),
avatar_url=_resolve_avatar_url(),
is_external=request.form.get("is_external") == "on",
is_app_user=bool(request.form.get("linked_user_id")),
linked_user_id=int(request.form["linked_user_id"]) if request.form.get("linked_user_id") else None,
is_active=True,
)
db.session.add(participant)
db.session.commit()
flash("Beteiligte Person angelegt.", "success")
return redirect(url_for("admin.index"))
@admin_bp.route("/participants/<int:participant_id>", methods=["POST"])
@login_required
@admin_required
def update_participant(participant_id: int):
participant = CostParticipant.query.get_or_404(participant_id)
participant.name = request.form["name"].strip()
participant.avatar_url = _resolve_avatar_url(participant.avatar_url)
participant.is_external = request.form.get("is_external") == "on"
participant.linked_user_id = (
int(request.form["linked_user_id"]) if request.form.get("linked_user_id") else None
)
participant.is_app_user = participant.linked_user_id is not None
participant.is_active = request.form.get("is_active") == "on"
db.session.commit()
flash("Beteiligte Person aktualisiert.", "success")
return redirect(url_for("admin.index"))
@admin_bp.route("/accounts", methods=["POST"])
@login_required
@admin_required
def create_account():
name = request.form["name"].strip()
account = Account(
name=name,
slug=slugify(request.form.get("slug", "") or name),
description=request.form.get("description", "").strip() or None,
sort_order=int(request.form.get("sort_order") or 0),
is_active=request.form.get("is_active") == "on",
)
db.session.add(account)
db.session.commit()
flash("Konto angelegt.", "success")
return redirect(url_for("admin.index"))
@admin_bp.route("/accounts/<int:account_id>", methods=["POST"])
@login_required
@admin_required
def update_account(account_id: int):
account = Account.query.get_or_404(account_id)
name = request.form["name"].strip()
account.name = name
account.slug = slugify(request.form.get("slug", "") or name)
account.description = request.form.get("description", "").strip() or None
account.sort_order = int(request.form.get("sort_order") or 0)
account.is_active = request.form.get("is_active") == "on"
db.session.commit()
flash("Konto aktualisiert.", "success")
return redirect(url_for("admin.index"))
@admin_bp.route("/categories", methods=["POST"])
@login_required
@admin_required
def create_category():
name = request.form["name"].strip()
category = Category(
account_id=int(request.form["account_id"]),
name=name,
slug=slugify(request.form.get("slug", "") or name),
description=request.form.get("description", "").strip() or None,
sort_order=int(request.form.get("sort_order") or 0),
is_active=request.form.get("is_active") == "on",
)
db.session.add(category)
db.session.commit()
flash("Kategorie angelegt.", "success")
return redirect(url_for("admin.index"))
@admin_bp.route("/categories/<int:category_id>", methods=["POST"])
@login_required
@admin_required
def update_category(category_id: int):
category = Category.query.get_or_404(category_id)
name = request.form["name"].strip()
category.account_id = int(request.form["account_id"])
category.name = name
category.slug = slugify(request.form.get("slug", "") or name)
category.description = request.form.get("description", "").strip() or None
category.sort_order = int(request.form.get("sort_order") or 0)
category.is_active = request.form.get("is_active") == "on"
db.session.commit()
flash("Kategorie aktualisiert.", "success")
return redirect(url_for("admin.index"))
@admin_bp.route("/entries", methods=["POST"])
@login_required
@admin_required
def create_entry():
name = request.form["name"].strip()
entry = Entry(
category_id=int(request.form["category_id"]),
name=name,
slug=slugify(request.form.get("slug", "") or name),
description=request.form.get("description", "").strip() or None,
default_amount=request.form.get("default_amount", "0"),
amount_type=request.form.get("amount_type", "fixed"),
sort_order=int(request.form.get("sort_order") or 0),
is_active=request.form.get("is_active") == "on",
)
db.session.add(entry)
db.session.flush()
for month in Month.query.order_by(Month.year.asc(), Month.month.asc()).all():
db.session.add(
MonthlyEntryValue(
month_id=month.id,
entry_id=entry.id,
planned_amount=entry.default_amount,
)
)
db.session.commit()
flash("Eintrag angelegt und in vorhandene Monate übernommen.", "success")
return redirect(url_for("admin.index"))
@admin_bp.route("/entries/<int:entry_id>", methods=["POST"])
@login_required
@admin_required
def update_entry(entry_id: int):
entry = Entry.query.get_or_404(entry_id)
name = request.form["name"].strip()
entry.category_id = int(request.form["category_id"])
entry.name = name
entry.slug = slugify(request.form.get("slug", "") or name)
entry.description = request.form.get("description", "").strip() or None
entry.default_amount = request.form.get("default_amount", "0")
entry.amount_type = request.form.get("amount_type", "fixed")
entry.sort_order = int(request.form.get("sort_order") or 0)
entry.is_active = request.form.get("is_active") == "on"
db.session.commit()
flash("Eintrag aktualisiert.", "success")
return redirect(url_for("admin.index"))
@admin_bp.route("/entries/<int:entry_id>/share-rules", methods=["POST"])
@login_required
@admin_required
def create_share_rule(entry_id: int):
participant_id = int(request.form["participant_id"])
rule = EntryShareRule.query.filter_by(entry_id=entry_id, participant_id=participant_id).first()
if rule is None:
rule = EntryShareRule(entry_id=entry_id, participant_id=participant_id)
db.session.add(rule)
rule.share_type = request.form.get("share_type", "equal")
share_value = request.form.get("share_value", "").strip()
rule.share_value = share_value or None
db.session.commit()
flash("Beteiligungsregel gespeichert.", "success")
return redirect(url_for("admin.index"))
@admin_bp.route("/share-rules/<int:rule_id>/delete", methods=["POST"])
@login_required
@admin_required
def delete_share_rule(rule_id: int):
rule = EntryShareRule.query.get_or_404(rule_id)
db.session.delete(rule)
db.session.commit()
flash("Beteiligungsregel entfernt.", "info")
return redirect(url_for("admin.index"))