Quellcode für backend.app_config

# libre-stage - Band rehearsal and gig management software
# Copyright (C) 2026  libre-stage contributors
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.

"""
Application configuration loader.

Reads and validates ``appConfig.json`` from the project root on import.
If the file is missing, malformed or incomplete the application exits
immediately with a descriptive error message.

Required top-level keys in ``appConfig.json``:
    ``genres``, ``gigTypes``, ``songStatuses``, ``gigStatuses``,
    ``tonekeys``, ``rehearsalSongStatuses``, ``setlist_timing``
"""

import json
import sys
import logging
import tempfile
from copy import deepcopy
from pathlib import Path
from threading import RLock
from datetime import datetime, timezone

logger = logging.getLogger("uvicorn.error")

_config_path = Path(__file__).parent.parent / "config/appConfig.json"

_REQUIRED_KEYS = [
    "genres",
    "gigTypes",
    "songStatuses",
    "gigStatuses",
    "tonekeys",
    "rehearsalSongStatuses",
    "setlist_timing",
]

SOFT_CONFIG_KEYS = tuple(_REQUIRED_KEYS)
_OBJECT_SOFT_KEYS = {"genres", "gigTypes", "songStatuses", "gigStatuses", "tonekeys"}
_STRING_SOFT_KEYS = {"rehearsalSongStatuses"}
_SETLIST_TIMING_SOFT_KEYS = {"setlist_timing"}
_SETLIST_TIMING_KEYS = (
    "DEFAULT_SONG_DURATION_SECONDS",
    "DEFAULT_INTER_SONG_BREAK_SECONDS",
    "DEFAULT_SET_PAUSE_SECONDS",
)


[Doku] class ConfigValidationError(ValueError): """Raised when admin-provided soft config payload is invalid."""
_config_lock = RLock() app_config: dict = {} def _validate_required_keys(config: dict) -> None: missing = [k for k in _REQUIRED_KEYS if k not in config] if missing: raise ConfigValidationError(f"appConfig.json ist unvollstaendig! Fehlende Keys: {', '.join(missing)}") def _load_config_from_disk() -> dict: with open(_config_path, "r", encoding="utf-8") as f: config = json.load(f) if not isinstance(config, dict): raise ConfigValidationError("appConfig.json muss ein JSON-Objekt sein") _validate_required_keys(config) return config
[Doku] def load_config() -> dict: """Load appConfig.json into the module-global config dict.""" with _config_lock: loaded = _load_config_from_disk() app_config.clear() app_config.update(loaded) logger.info(f"App config loaded from: {_config_path}") return deepcopy(app_config)
[Doku] def get_config() -> dict: """Return a deep copy of the full application configuration.""" with _config_lock: return deepcopy(app_config)
[Doku] def get_soft_config() -> dict: """Return only admin-editable soft config keys.""" with _config_lock: return {key: deepcopy(app_config[key]) for key in SOFT_CONFIG_KEYS}
[Doku] def get_soft_config_updated_at() -> str: """Return the last modification time of appConfig.json in ISO8601.""" try: ts = _config_path.stat().st_mtime return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat().replace("+00:00", "Z") except OSError: return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _normalize_object_list_item(key: str, item): if isinstance(item, str): stripped = item.strip() if not stripped: return None return {"key": stripped, "label": stripped} if not isinstance(item, dict): raise ConfigValidationError(f"{key}: Eintraege muessen String oder Objekt sein") key_val = item.get("key") label_val = item.get("label", key_val) if isinstance(key_val, str): key_val = key_val.strip() if isinstance(label_val, str): label_val = label_val.strip() if key == "tonekeys" and key_val is None: if label_val is None: label_val = "" if not isinstance(label_val, str): raise ConfigValidationError("tonekeys: label muss String sein") return {"key": None, "label": label_val} if not isinstance(key_val, str) or not key_val: raise ConfigValidationError(f"{key}: key muss ein nicht-leerer String sein") if not isinstance(label_val, str) or not label_val: label_val = key_val return {"key": key_val, "label": label_val} def _normalize_string_list_item(key: str, item): if isinstance(item, str): stripped = item.strip() return stripped or None if isinstance(item, dict): value = item.get("key", item.get("label")) if isinstance(value, str): stripped = value.strip() return stripped or None raise ConfigValidationError(f"{key}: Eintraege muessen Strings sein") def _normalize_setlist_timing_entry(item): if not isinstance(item, dict) or len(item) != 1: raise ConfigValidationError( "setlist_timing: Jeder Eintrag muss ein Objekt mit genau einem Timing-Key sein" ) timing_key, raw_value = next(iter(item.items())) if timing_key not in _SETLIST_TIMING_KEYS: raise ConfigValidationError( f"setlist_timing: Unbekannter Timing-Key '{timing_key}'" ) if isinstance(raw_value, bool): raise ConfigValidationError( f"setlist_timing: '{timing_key}' muss eine Zahl in Sekunden sein" ) if isinstance(raw_value, str): raw_value = raw_value.strip() if not raw_value: raise ConfigValidationError( f"setlist_timing: '{timing_key}' darf nicht leer sein" ) try: value = int(raw_value) except ValueError as exc: raise ConfigValidationError( f"setlist_timing: '{timing_key}' muss eine ganze Zahl sein" ) from exc elif isinstance(raw_value, float): if not raw_value.is_integer(): raise ConfigValidationError( f"setlist_timing: '{timing_key}' muss eine ganze Zahl sein" ) value = int(raw_value) elif isinstance(raw_value, int): value = raw_value else: raise ConfigValidationError( f"setlist_timing: '{timing_key}' muss eine ganze Zahl sein" ) if value < 0: raise ConfigValidationError( f"setlist_timing: '{timing_key}' darf nicht negativ sein" ) return timing_key, value def _normalize_soft_list(key: str, value): if not isinstance(value, list): raise ConfigValidationError(f"{key}: Wert muss eine Liste sein") normalized = [] seen = set() if key in _OBJECT_SOFT_KEYS: for item in value: normalized_item = _normalize_object_list_item(key, item) if normalized_item is None: continue dedupe_token = (normalized_item.get("key"), normalized_item.get("label")) if dedupe_token in seen: continue seen.add(dedupe_token) normalized.append(normalized_item) return normalized if key in _STRING_SOFT_KEYS: for item in value: normalized_item = _normalize_string_list_item(key, item) if normalized_item is None or normalized_item in seen: continue seen.add(normalized_item) normalized.append(normalized_item) return normalized if key in _SETLIST_TIMING_SOFT_KEYS: timing_values = {} for item in value: timing_key, timing_seconds = _normalize_setlist_timing_entry(item) timing_values[timing_key] = timing_seconds missing = [timing_key for timing_key in _SETLIST_TIMING_KEYS if timing_key not in timing_values] if missing: raise ConfigValidationError( "setlist_timing: Fehlende Timing-Keys: " + ", ".join(missing) ) return [{timing_key: timing_values[timing_key]} for timing_key in _SETLIST_TIMING_KEYS] raise ConfigValidationError(f"Nicht editierbarer Key: {key}") def _write_config_atomic(config: dict) -> None: with tempfile.NamedTemporaryFile( mode="w", encoding="utf-8", dir=_config_path.parent, delete=False, suffix=".tmp", ) as tmp: json.dump(config, tmp, ensure_ascii=False, indent=2) tmp.write("\n") temp_path = Path(tmp.name) temp_path.replace(_config_path)
[Doku] def update_soft_config(payload: dict) -> dict: """Validate, persist and reload the editable soft config keys.""" if not isinstance(payload, dict): raise ConfigValidationError("Payload muss ein JSON-Objekt sein") keys = set(payload.keys()) allowed = set(SOFT_CONFIG_KEYS) unknown_keys = sorted(keys - allowed) if unknown_keys: raise ConfigValidationError(f"Unbekannte Keys: {', '.join(unknown_keys)}") missing_keys = sorted(allowed - keys) if missing_keys: raise ConfigValidationError(f"Fehlende Keys: {', '.join(missing_keys)}") normalized = {key: _normalize_soft_list(key, payload[key]) for key in SOFT_CONFIG_KEYS} with _config_lock: current = _load_config_from_disk() current.update(normalized) _validate_required_keys(current) _write_config_atomic(current) app_config.clear() app_config.update(current) return get_soft_config()
try: load_config() except FileNotFoundError: print( f"\n{'=' * 60}\n" f"FATAL: appConfig.json nicht gefunden!\n" f"Erwarteter Pfad: {_config_path}\n\n" f"Bitte erstelle die Datei unter config/appConfig.json.\n" f"Eine Vorlage findest du in der Dokumentation.\n" f"{'=' * 60}\n", file=sys.stderr, ) sys.exit(1) except json.JSONDecodeError as e: print( f"\n{'=' * 60}\n" f"FATAL: appConfig.json enthält ungültiges JSON!\n" f"Pfad: {_config_path}\n" f"Fehler: {e}\n" f"{'=' * 60}\n", file=sys.stderr, ) sys.exit(1) except ConfigValidationError as e: print( f"\n{'=' * 60}\n" f"FATAL: appConfig.json ist unvollstaendig oder ungueltig!\n" f"Fehler: {e}\n" f"Pfad: {_config_path}\n" f"{'=' * 60}\n", file=sys.stderr, ) sys.exit(1)
[Doku] def get_frontend_config() -> dict: """ Return only the configuration keys relevant to the frontend. Returns: dict: A dictionary containing ``genres``, ``gigTypes``, ``songStatuses``, ``gigStatuses``, ``tonekeys`` and ``rehearsalSongStatuses``. """ return get_soft_config()