docker.dateiverwaltung/Source/backend/app/services/backup_service.py
data c5ee82e1c2 V 2.0 - Feinsortierung Live-Streaming, Import/Export, PDF-Rotation
Neue Features:
- Feinsortierung mit Live-Streaming (SSE) - zeigt Fortschritt in Echtzeit
- Import/Export für Sortierregeln (JSON)
- Sortierregeln-Liste mit Sortieroptionen (Name A-Z/Z-A, Priorität)
- Checkbox "Auch Dateinamen prüfen" für Keyword-Matching
- Automatische PDF-Seitenrotation bei OCR (90°, 180°, 270°)
- Tab-Persistenz über Page-Reload (localStorage)
- Modals schließen nur noch über X-Button

Bugfixes:
- Keywords nutzen jetzt Wortgrenzen (\b) - "rechnung" matched nicht mehr "Berechnung"
- Keyword-Prüfung standardmäßig nur auf PDF-Text, nicht Dateinamen
- Natürliche Sortierung für Regelnamen (1, 2, 10 statt 1, 10, 2)

Technisch:
- Async SSE-Generator mit asyncio.sleep(0) für sofortiges Streaming
- ocrmypdf mit --rotate-pages Flag
- Timeout für OCR auf 3 Minuten erhöht

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 13:42:12 +01:00

262 lines
7.6 KiB
Python
Executable file

"""
Datenbank-Backup Service
Unterstützt MariaDB, MySQL und PostgreSQL
"""
import subprocess
import os
import gzip
import zipfile
from pathlib import Path
from datetime import datetime
from sqlalchemy.orm import Session
import logging
from ..models.database import DbServer, Datenbank, BackupLog
logger = logging.getLogger(__name__)
def erstelle_db_backup(datenbank_id: int, db: Session) -> dict:
"""
Erstellt ein Backup einer Datenbank.
Args:
datenbank_id: ID der Datenbank-Konfiguration
db: SQLAlchemy Session
Returns:
dict mit Backup-Informationen
"""
# Datenbank-Konfiguration laden
datenbank = db.query(Datenbank).filter(Datenbank.id == datenbank_id).first()
if not datenbank:
raise Exception("Datenbank nicht gefunden")
# Server-Konfiguration laden
server = db.query(DbServer).filter(DbServer.id == datenbank.server_id).first()
if not server:
raise Exception("DB-Server nicht gefunden")
# Backup-Verzeichnis erstellen
backup_dir = Path(datenbank.backup_pfad)
backup_dir.mkdir(parents=True, exist_ok=True)
# Dateiname generieren
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
base_name = f"{datenbank.database}_{timestamp}"
try:
# Backup erstellen basierend auf DB-Typ
if server.typ in ["mariadb", "mysql"]:
sql_file = backup_mysql(server, datenbank.database, backup_dir, base_name)
elif server.typ == "postgresql":
sql_file = backup_postgresql(server, datenbank.database, backup_dir, base_name)
else:
raise Exception(f"Unbekannter Datenbanktyp: {server.typ}")
# Format anwenden (Komprimierung)
final_file = sql_file
if datenbank.format == "sql.gz":
final_file = komprimiere_gzip(sql_file)
os.remove(sql_file) # Original löschen
elif datenbank.format == "zip":
final_file = komprimiere_zip(sql_file)
os.remove(sql_file) # Original löschen
# Dateigröße ermitteln
file_size = os.path.getsize(final_file)
# Backup in DB loggen
log_entry = BackupLog(
datenbank_id=datenbank.id,
dateiname=os.path.basename(final_file),
groesse_bytes=file_size,
status="erfolg"
)
db.add(log_entry)
# Datenbank-Status aktualisieren
datenbank.letztes_backup = datetime.now()
datenbank.letzte_groesse_mb = round(file_size / 1024 / 1024)
db.commit()
# Alte Backups aufräumen
bereinige_alte_backups(datenbank, db)
logger.info(f"Backup erstellt: {final_file} ({file_size} bytes)")
print(f"[BACKUP] ✓ Backup erstellt: {final_file}", flush=True)
return {
"erfolg": True,
"datei": os.path.basename(final_file),
"groesse_mb": round(file_size / 1024 / 1024, 2),
"pfad": str(final_file)
}
except Exception as e:
# Fehler loggen
log_entry = BackupLog(
datenbank_id=datenbank.id,
dateiname=f"{base_name}_FEHLER",
status="fehler",
fehler=str(e)
)
db.add(log_entry)
db.commit()
logger.error(f"Backup-Fehler: {e}")
print(f"[BACKUP] ✗ Fehler: {e}", flush=True)
raise Exception(f"Backup fehlgeschlagen: {e}")
def backup_mysql(server: DbServer, database: str, backup_dir: Path, base_name: str) -> str:
"""
Erstellt ein MySQL/MariaDB Backup mit mysqldump.
"""
output_file = backup_dir / f"{base_name}.sql"
cmd = [
"mysqldump",
f"--host={server.host}",
f"--port={server.port}",
f"--user={server.user}",
f"--password={server.passwort}",
"--single-transaction",
"--routines",
"--triggers",
"--events",
database
]
print(f"[BACKUP] Starte MySQL-Dump für {database}...", flush=True)
with open(output_file, 'w') as f:
result = subprocess.run(cmd, stdout=f, stderr=subprocess.PIPE, timeout=3600)
if result.returncode != 0:
error_msg = result.stderr.decode('utf-8', errors='ignore')
raise Exception(f"mysqldump Fehler: {error_msg}")
return str(output_file)
def backup_postgresql(server: DbServer, database: str, backup_dir: Path, base_name: str) -> str:
"""
Erstellt ein PostgreSQL Backup mit pg_dump.
"""
output_file = backup_dir / f"{base_name}.sql"
# Passwort über Umgebungsvariable
env = os.environ.copy()
env["PGPASSWORD"] = server.passwort
cmd = [
"pg_dump",
f"--host={server.host}",
f"--port={server.port}",
f"--username={server.user}",
"--format=plain",
"--no-owner",
"--no-acl",
database
]
print(f"[BACKUP] Starte PostgreSQL-Dump für {database}...", flush=True)
with open(output_file, 'w') as f:
result = subprocess.run(cmd, stdout=f, stderr=subprocess.PIPE, env=env, timeout=3600)
if result.returncode != 0:
error_msg = result.stderr.decode('utf-8', errors='ignore')
raise Exception(f"pg_dump Fehler: {error_msg}")
return str(output_file)
def komprimiere_gzip(input_file: str) -> str:
"""
Komprimiert eine Datei mit gzip.
"""
output_file = f"{input_file}.gz"
with open(input_file, 'rb') as f_in:
with gzip.open(output_file, 'wb') as f_out:
f_out.writelines(f_in)
return output_file
def komprimiere_zip(input_file: str) -> str:
"""
Komprimiert eine Datei als ZIP.
"""
output_file = input_file.replace('.sql', '.zip')
with zipfile.ZipFile(output_file, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.write(input_file, os.path.basename(input_file))
return output_file
def bereinige_alte_backups(datenbank: Datenbank, db: Session):
"""
Löscht alte Backups basierend auf der Aufbewahrungsregel.
"""
backup_dir = Path(datenbank.backup_pfad)
if not backup_dir.exists():
return
# Alle Backup-Dateien für diese Datenbank finden
pattern = f"{datenbank.database}_*"
backups = sorted(backup_dir.glob(pattern), key=lambda x: x.stat().st_mtime, reverse=True)
# Nur die neuesten behalten
zu_loeschen = backups[datenbank.aufbewahrung:]
for backup_file in zu_loeschen:
try:
backup_file.unlink()
print(f"[BACKUP] Altes Backup gelöscht: {backup_file.name}", flush=True)
# Auch aus der DB löschen
db.query(BackupLog).filter(BackupLog.dateiname == backup_file.name).delete()
except Exception as e:
logger.warning(f"Konnte altes Backup nicht löschen: {e}")
db.commit()
def fuehre_alle_backups_aus(db: Session) -> dict:
"""
Führt Backups für alle aktiven Datenbanken aus.
Returns:
dict mit Zusammenfassung
"""
datenbanken = db.query(Datenbank).filter(Datenbank.aktiv == True).all()
ergebnis = {
"gesamt": len(datenbanken),
"erfolgreich": 0,
"fehlgeschlagen": 0,
"details": []
}
for datenbank in datenbanken:
try:
result = erstelle_db_backup(datenbank.id, db)
ergebnis["erfolgreich"] += 1
ergebnis["details"].append({
"name": datenbank.name,
"status": "erfolg",
"datei": result.get("datei")
})
except Exception as e:
ergebnis["fehlgeschlagen"] += 1
ergebnis["details"].append({
"name": datenbank.name,
"status": "fehler",
"fehler": str(e)
})
return ergebnis