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>
262 lines
7.6 KiB
Python
Executable file
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
|