release: publish saldo 0.1.0
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import wraps
|
||||
|
||||
from flask import abort
|
||||
from flask_login import current_user
|
||||
|
||||
|
||||
def admin_required(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not current_user.is_authenticated or not current_user.is_admin():
|
||||
abort(403)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
@@ -0,0 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
def currency(value: Decimal | float | int) -> str:
|
||||
amount = Decimal(str(value or 0)).quantize(Decimal("0.01"))
|
||||
formatted = f"{amount:,.2f}".replace(",", "_").replace(".", ",").replace("_", ".")
|
||||
return f"{formatted} €"
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from flask import current_app, url_for
|
||||
from werkzeug.datastructures import FileStorage
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
|
||||
ALLOWED_AVATAR_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
||||
|
||||
|
||||
def save_avatar_upload(file_storage: FileStorage | None) -> str | None:
|
||||
if file_storage is None or not file_storage.filename:
|
||||
return None
|
||||
|
||||
original_name = secure_filename(file_storage.filename)
|
||||
suffix = Path(original_name).suffix.lower()
|
||||
if suffix not in ALLOWED_AVATAR_EXTENSIONS:
|
||||
raise ValueError("Bitte ein Bild als PNG, JPG, WEBP oder GIF hochladen.")
|
||||
|
||||
upload_dir = Path(current_app.config["AVATAR_UPLOAD_DIR"])
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
filename = f"{uuid4().hex}{suffix}"
|
||||
file_storage.save(upload_dir / filename)
|
||||
return url_for("main.uploaded_avatar", filename=filename)
|
||||
@@ -0,0 +1,171 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from itertools import combinations
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import CostParticipant, User
|
||||
|
||||
|
||||
GERMAN_MONTH_NAMES = {
|
||||
1: "Januar",
|
||||
2: "Februar",
|
||||
3: "Maerz",
|
||||
4: "April",
|
||||
5: "Mai",
|
||||
6: "Juni",
|
||||
7: "Juli",
|
||||
8: "August",
|
||||
9: "September",
|
||||
10: "Oktober",
|
||||
11: "November",
|
||||
12: "Dezember",
|
||||
}
|
||||
|
||||
|
||||
def active_users() -> list[User]:
|
||||
users = db.session.scalars(
|
||||
select(User).where(User.is_active.is_(True)).order_by(User.role.asc(), User.display_name.asc())
|
||||
).all()
|
||||
return list(users)
|
||||
|
||||
|
||||
def personal_users() -> list[User]:
|
||||
users = active_users()
|
||||
non_admin = [user for user in users if not user.is_admin()]
|
||||
return (non_admin or users)[:2]
|
||||
|
||||
|
||||
def personal_account_names() -> dict[str, str]:
|
||||
users = personal_users()
|
||||
names = {
|
||||
"persoenlich-flo": users[0].ui_name if len(users) > 0 else "Persoenlich 1",
|
||||
"persoenlich-desi": users[1].ui_name if len(users) > 1 else "Persoenlich 2",
|
||||
}
|
||||
return names
|
||||
|
||||
|
||||
def format_planning_month(label: str) -> str:
|
||||
year, month = [int(part) for part in label.split("-", 1)]
|
||||
return f"{GERMAN_MONTH_NAMES.get(month, label)} {year}"
|
||||
|
||||
|
||||
def sync_user_participants() -> bool:
|
||||
changed = False
|
||||
users = db.session.scalars(select(User).order_by(User.id.asc())).all()
|
||||
existing_by_user_id = {
|
||||
participant.linked_user_id: participant
|
||||
for participant in db.session.scalars(
|
||||
select(CostParticipant).where(CostParticipant.is_app_user.is_(True))
|
||||
).all()
|
||||
if participant.linked_user_id is not None
|
||||
}
|
||||
|
||||
for user in users:
|
||||
participant = existing_by_user_id.get(user.id)
|
||||
if participant is None:
|
||||
participant = CostParticipant(
|
||||
name=user.ui_name,
|
||||
avatar_url=user.avatar_url,
|
||||
is_app_user=True,
|
||||
linked_user_id=user.id,
|
||||
is_external=False,
|
||||
is_active=user.is_active,
|
||||
)
|
||||
db.session.add(participant)
|
||||
changed = True
|
||||
continue
|
||||
if participant.name != user.ui_name:
|
||||
participant.name = user.ui_name
|
||||
changed = True
|
||||
if participant.avatar_url != user.avatar_url:
|
||||
participant.avatar_url = user.avatar_url
|
||||
changed = True
|
||||
if participant.is_external:
|
||||
participant.is_external = False
|
||||
changed = True
|
||||
if participant.is_app_user is not True:
|
||||
participant.is_app_user = True
|
||||
changed = True
|
||||
if participant.is_active != user.is_active:
|
||||
participant.is_active = user.is_active
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def encode_benefit_scope(selected_user_ids: list[int] | set[int], available_users: list[User]) -> str:
|
||||
available_ids = [user.id for user in available_users]
|
||||
selected_ids = [user_id for user_id in available_ids if user_id in set(selected_user_ids)]
|
||||
if not selected_ids or selected_ids == available_ids:
|
||||
return "all-users"
|
||||
return "users:" + ",".join(str(user_id) for user_id in selected_ids)
|
||||
|
||||
|
||||
def decode_benefit_scope(scope: str | None, available_users: list[User]) -> list[int]:
|
||||
available_ids = [user.id for user in available_users]
|
||||
if not available_ids:
|
||||
return []
|
||||
if scope in {None, "", "both", "all-users"}:
|
||||
return available_ids
|
||||
if scope in {"flo", "desi"}:
|
||||
mapping = personal_account_names()
|
||||
label = mapping["persoenlich-flo"] if scope == "flo" else mapping["persoenlich-desi"]
|
||||
return [user.id for user in available_users if user.ui_name == label][:1] or available_ids
|
||||
if scope.startswith("users:"):
|
||||
parsed_ids = []
|
||||
for raw_id in scope.removeprefix("users:").split(","):
|
||||
raw_id = raw_id.strip()
|
||||
if raw_id.isdigit():
|
||||
parsed_ids.append(int(raw_id))
|
||||
normalized = [user_id for user_id in available_ids if user_id in parsed_ids]
|
||||
return normalized or available_ids
|
||||
return available_ids
|
||||
|
||||
|
||||
def benefit_scope_label(scope: str | None, available_users: list[User]) -> str:
|
||||
selected_ids = decode_benefit_scope(scope, available_users)
|
||||
users_by_id = {user.id: user for user in available_users}
|
||||
names = [users_by_id[user_id].ui_name for user_id in selected_ids if user_id in users_by_id]
|
||||
if not names:
|
||||
return "Alle Nutzer"
|
||||
if len(names) == len(available_users):
|
||||
return "Alle Nutzer"
|
||||
if len(names) == 1:
|
||||
return names[0]
|
||||
if len(names) == 2:
|
||||
return " & ".join(names)
|
||||
return ", ".join(names[:-1]) + f" & {names[-1]}"
|
||||
|
||||
|
||||
def benefit_scope_options(available_users: list[User]) -> list[dict[str, str]]:
|
||||
if not available_users:
|
||||
return [{"value": "all-users", "label": "Alle Nutzer"}]
|
||||
|
||||
options: list[dict[str, str]] = []
|
||||
available_ids = [user.id for user in available_users]
|
||||
for size in range(len(available_users), 0, -1):
|
||||
for user_combo in combinations(available_users, size):
|
||||
user_ids = [user.id for user in user_combo]
|
||||
value = encode_benefit_scope(user_ids, available_users)
|
||||
if size == len(available_users):
|
||||
label = "Alle Nutzer"
|
||||
elif size == 1:
|
||||
label = user_combo[0].ui_name
|
||||
elif size == 2:
|
||||
label = " & ".join(user.ui_name for user in user_combo)
|
||||
else:
|
||||
label = ", ".join(user.ui_name for user in user_combo[:-1]) + f" & {user_combo[-1].ui_name}"
|
||||
options.append({"value": value, "label": label})
|
||||
|
||||
seen_values = set()
|
||||
deduplicated = []
|
||||
for option in options:
|
||||
if option["value"] in seen_values:
|
||||
continue
|
||||
seen_values.add(option["value"])
|
||||
deduplicated.append(option)
|
||||
|
||||
if "all-users" not in seen_values:
|
||||
deduplicated.insert(0, {"value": encode_benefit_scope(available_ids, available_users), "label": "Alle Nutzer"})
|
||||
return deduplicated
|
||||
Reference in New Issue
Block a user