d2dd837f26
A Python CLI tool for generating financial reports from Paperless-ngx: - Phase 1 (MVP): Config handling, Paperless API client with auth and pagination, custom fields extraction, tag-based summation, CLI output - Phase 2 (Grouping): Multiple grouping criteria (tag, correspondent, category, payment type, month, quarter, year), percentage distribution - Phase 3 (Reports): HTML reports with Chart.js diagrams (doughnut, bar, line charts), PDF export via WeasyPrint, JSON and CSV export - Phase 4 (Comfort): Automatic tag ID resolution, disk caching with diskcache, colorized logging, comprehensive error handling Features: - Flexible date filtering (year, month, date range) - Period comparison with change analysis - Swiss franc formatting (CHF with apostrophe separators) - Interactive HTML reports with sortable tables and document links - Multiple output formats (CLI, HTML, PDF, JSON, CSV)
270 lines
7.7 KiB
Python
270 lines
7.7 KiB
Python
"""
|
|
Konfigurationsmanagement für das Paperless Finance Report Tool.
|
|
|
|
Lädt und validiert die YAML-Konfiguration.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
import yaml
|
|
|
|
|
|
class ConfigError(Exception):
|
|
"""Fehler bei der Konfiguration."""
|
|
pass
|
|
|
|
|
|
class Config:
|
|
"""Konfigurationsklasse für das Paperless Finance Report Tool."""
|
|
|
|
DEFAULT_CONFIG = {
|
|
'paperless': {
|
|
'url': 'http://localhost:8000',
|
|
'token': '',
|
|
'timeout': 30,
|
|
},
|
|
'custom_fields': {
|
|
'betrag': 'betrag',
|
|
'rechnungsdatum': 'rechnungsdatum',
|
|
'kategorie': 'kategorie',
|
|
'zahlungsart': 'zahlungsart',
|
|
'periode': 'periode',
|
|
'notiz': 'notiz',
|
|
},
|
|
'defaults': {
|
|
'currency': 'CHF',
|
|
'date_field': 'archive_date',
|
|
'invoice_tag': 'rechnung',
|
|
},
|
|
'tags': ['rechnung'],
|
|
'categories': [],
|
|
'output': {
|
|
'format': 'html',
|
|
'path': './output',
|
|
'filename_pattern': 'finanzbericht_{year}',
|
|
},
|
|
'cache': {
|
|
'enabled': True,
|
|
'path': './.cache',
|
|
'ttl': 3600,
|
|
},
|
|
'logging': {
|
|
'level': 'INFO',
|
|
'file': '',
|
|
'colorize': True,
|
|
},
|
|
}
|
|
|
|
def __init__(self, config_path: Optional[str] = None):
|
|
"""
|
|
Initialisiert die Konfiguration.
|
|
|
|
Args:
|
|
config_path: Pfad zur config.yaml. Falls None, wird im aktuellen
|
|
Verzeichnis und im Script-Verzeichnis gesucht.
|
|
"""
|
|
self._config = self.DEFAULT_CONFIG.copy()
|
|
self._config_path = self._find_config(config_path)
|
|
|
|
if self._config_path:
|
|
self._load_config()
|
|
|
|
self._validate_config()
|
|
|
|
def _find_config(self, config_path: Optional[str]) -> Optional[Path]:
|
|
"""Sucht nach der Konfigurationsdatei."""
|
|
if config_path:
|
|
path = Path(config_path)
|
|
if path.exists():
|
|
return path
|
|
raise ConfigError(f"Konfigurationsdatei nicht gefunden: {config_path}")
|
|
|
|
# Suchpfade
|
|
search_paths = [
|
|
Path.cwd() / 'config.yaml',
|
|
Path.cwd() / 'config.yml',
|
|
Path(__file__).parent / 'config.yaml',
|
|
Path(__file__).parent / 'config.yml',
|
|
Path.home() / '.config' / 'paperless-report' / 'config.yaml',
|
|
]
|
|
|
|
# Umgebungsvariable prüfen
|
|
env_path = os.environ.get('PAPERLESS_REPORT_CONFIG')
|
|
if env_path:
|
|
search_paths.insert(0, Path(env_path))
|
|
|
|
for path in search_paths:
|
|
if path.exists():
|
|
return path
|
|
|
|
return None
|
|
|
|
def _load_config(self) -> None:
|
|
"""Lädt die Konfiguration aus der YAML-Datei."""
|
|
try:
|
|
with open(self._config_path, 'r', encoding='utf-8') as f:
|
|
user_config = yaml.safe_load(f) or {}
|
|
|
|
# Rekursives Merge der Konfiguration
|
|
self._config = self._deep_merge(self._config, user_config)
|
|
|
|
except yaml.YAMLError as e:
|
|
raise ConfigError(f"Fehler beim Parsen der Konfiguration: {e}")
|
|
except IOError as e:
|
|
raise ConfigError(f"Fehler beim Lesen der Konfiguration: {e}")
|
|
|
|
def _deep_merge(self, base: dict, override: dict) -> dict:
|
|
"""Führt zwei Dictionaries rekursiv zusammen."""
|
|
result = base.copy()
|
|
|
|
for key, value in override.items():
|
|
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
result[key] = self._deep_merge(result[key], value)
|
|
else:
|
|
result[key] = value
|
|
|
|
return result
|
|
|
|
def _validate_config(self) -> None:
|
|
"""Validiert die Konfiguration."""
|
|
# Paperless URL prüfen
|
|
url = self.get('paperless.url', '')
|
|
if not url:
|
|
raise ConfigError("Paperless URL muss konfiguriert werden")
|
|
|
|
# Token prüfen (kann auch über Umgebungsvariable kommen)
|
|
token = self.get('paperless.token', '') or os.environ.get('PAPERLESS_TOKEN', '')
|
|
if not token:
|
|
raise ConfigError(
|
|
"Paperless API-Token muss konfiguriert werden.\n"
|
|
"Setze 'paperless.token' in config.yaml oder die Umgebungsvariable PAPERLESS_TOKEN"
|
|
)
|
|
|
|
# Token aus Umgebungsvariable übernehmen falls nicht in Config
|
|
if not self.get('paperless.token'):
|
|
self._config['paperless']['token'] = token
|
|
|
|
def get(self, key: str, default: Any = None) -> Any:
|
|
"""
|
|
Holt einen Konfigurationswert über Punkt-Notation.
|
|
|
|
Args:
|
|
key: Schlüssel in Punkt-Notation, z.B. 'paperless.url'
|
|
default: Standardwert falls Schlüssel nicht existiert
|
|
|
|
Returns:
|
|
Der Konfigurationswert oder der Standardwert
|
|
"""
|
|
keys = key.split('.')
|
|
value = self._config
|
|
|
|
try:
|
|
for k in keys:
|
|
value = value[k]
|
|
return value
|
|
except (KeyError, TypeError):
|
|
return default
|
|
|
|
def __getitem__(self, key: str) -> Any:
|
|
"""Ermöglicht Zugriff via config['key']."""
|
|
value = self.get(key)
|
|
if value is None:
|
|
raise KeyError(key)
|
|
return value
|
|
|
|
@property
|
|
def paperless_url(self) -> str:
|
|
"""Paperless Base-URL."""
|
|
url = self.get('paperless.url', '')
|
|
return url.rstrip('/')
|
|
|
|
@property
|
|
def paperless_token(self) -> str:
|
|
"""Paperless API-Token."""
|
|
return self.get('paperless.token', '')
|
|
|
|
@property
|
|
def timeout(self) -> int:
|
|
"""Request-Timeout in Sekunden."""
|
|
return self.get('paperless.timeout', 30)
|
|
|
|
@property
|
|
def currency(self) -> str:
|
|
"""Standardwährung."""
|
|
return self.get('defaults.currency', 'CHF')
|
|
|
|
@property
|
|
def date_field(self) -> str:
|
|
"""Datumsfeld für Filterung."""
|
|
return self.get('defaults.date_field', 'archive_date')
|
|
|
|
@property
|
|
def output_format(self) -> str:
|
|
"""Standard-Ausgabeformat."""
|
|
return self.get('output.format', 'html')
|
|
|
|
@property
|
|
def output_path(self) -> Path:
|
|
"""Ausgabeverzeichnis."""
|
|
return Path(self.get('output.path', './output'))
|
|
|
|
@property
|
|
def cache_enabled(self) -> bool:
|
|
"""Cache aktiviert."""
|
|
return self.get('cache.enabled', True)
|
|
|
|
@property
|
|
def cache_path(self) -> Path:
|
|
"""Cache-Verzeichnis."""
|
|
return Path(self.get('cache.path', './.cache'))
|
|
|
|
@property
|
|
def cache_ttl(self) -> int:
|
|
"""Cache-Gültigkeit in Sekunden."""
|
|
return self.get('cache.ttl', 3600)
|
|
|
|
@property
|
|
def log_level(self) -> str:
|
|
"""Log-Level."""
|
|
return self.get('logging.level', 'INFO')
|
|
|
|
@property
|
|
def custom_field_names(self) -> dict:
|
|
"""Mapping der Custom Field Namen."""
|
|
return self.get('custom_fields', {})
|
|
|
|
def get_custom_field_name(self, internal_name: str) -> str:
|
|
"""Holt den Paperless-Feldnamen für ein internes Feld."""
|
|
return self.get(f'custom_fields.{internal_name}', internal_name)
|
|
|
|
|
|
# Globale Config-Instanz (lazy loading)
|
|
_config: Optional[Config] = None
|
|
|
|
|
|
def get_config(config_path: Optional[str] = None) -> Config:
|
|
"""
|
|
Holt die globale Konfiguration.
|
|
|
|
Args:
|
|
config_path: Optionaler Pfad zur Konfigurationsdatei
|
|
|
|
Returns:
|
|
Config-Instanz
|
|
"""
|
|
global _config
|
|
|
|
if _config is None or config_path is not None:
|
|
_config = Config(config_path)
|
|
|
|
return _config
|
|
|
|
|
|
def reset_config() -> None:
|
|
"""Setzt die globale Konfiguration zurück (für Tests)."""
|
|
global _config
|
|
_config = None
|