""" API Routes - Getrennte Bereiche: Mail-Abruf und Datei-Sortierung """ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session from typing import List, Optional from pydantic import BaseModel from datetime import datetime from pathlib import Path import json import asyncio import tempfile import re from ..models.database import get_db, Postfach, QuellOrdner, SortierRegel, VerarbeiteteDatei, VerarbeiteteMail, Zeitplan, OrdnerRegel, DbServer, Datenbank, BackupLog from ..modules.mail_fetcher import MailFetcher from ..modules.pdf_processor import PDFProcessor from ..modules.sorter import Sorter import logging logger = logging.getLogger(__name__) router = APIRouter(prefix="/api", tags=["api"]) # ============ Pydantic Models ============ class PostfachCreate(BaseModel): name: str imap_server: str imap_port: int = 993 email: str passwort: str ordner: str = "INBOX" alle_ordner: bool = False # Alle IMAP-Ordner durchsuchen nur_ungelesen: bool = False # Nur ungelesene Mails (False = alle) ziel_ordner: str erlaubte_typen: List[str] = [".pdf"] max_groesse_mb: int = 25 min_groesse_kb: int = 10 # Mindestgröße (gegen kleine Icons) ab_datum: Optional[datetime] = None # Nur Mails ab diesem Datum # Größenfilter pro Dateityp: {".pdf": {"min_kb": 10, "max_mb": 25}} groessen_filter: Optional[dict] = None class PostfachUpdate(BaseModel): """Für Updates - Passwort ist optional""" name: str imap_server: str imap_port: int = 993 email: str passwort: Optional[str] = None # Optional beim Update ordner: str = "INBOX" alle_ordner: bool = False nur_ungelesen: bool = False ziel_ordner: str erlaubte_typen: List[str] = [".pdf"] max_groesse_mb: int = 25 min_groesse_kb: int = 10 ab_datum: Optional[datetime] = None groessen_filter: Optional[dict] = None class PostfachResponse(BaseModel): id: int name: str imap_server: str email: str ordner: str alle_ordner: bool nur_ungelesen: bool ziel_ordner: str erlaubte_typen: List[str] max_groesse_mb: int min_groesse_kb: int ab_datum: Optional[datetime] groessen_filter: Optional[dict] aktiv: bool letzter_abruf: Optional[datetime] letzte_anzahl: int class Config: from_attributes = True class OrdnerCreate(BaseModel): name: str pfad: str ziel_ordner: str rekursiv: bool = True dateitypen: List[str] = [".pdf", ".jpg", ".jpeg", ".png", ".tiff"] zugferd_behandlung: str = "separieren" # separieren, regel, normal, ignorieren signiert_behandlung: str = "normal" # normal, separieren, regel, ignorieren direkt_verschieben: bool = False # Ohne Regelprüfung verschieben ocr_aktivieren: bool = True # OCR für gescannte PDFs original_sichern: Optional[str] = None # Ordner für Original-Backup class OrdnerResponse(BaseModel): id: int name: str pfad: str ziel_ordner: str rekursiv: bool dateitypen: List[str] direkt_verschieben: bool = False zugferd_behandlung: str signiert_behandlung: str = "normal" ocr_aktivieren: bool = True original_sichern: Optional[str] = None aktiv: bool letzte_verarbeitung: Optional[datetime] = None letzte_anzahl: int = 0 class Config: from_attributes = True class RegelCreate(BaseModel): name: str prioritaet: int = 100 muster: dict = {} extraktion: dict = {} schema: str = "{datum} - Dokument.pdf" unterordner: Optional[str] = None ist_fallback: bool = False ziel_ordner: Optional[str] = None # Ziel-Ordner für diese Regel nur_umbenennen: bool = False # Nur umbenennen, nicht verschieben class RegelResponse(BaseModel): id: int name: str prioritaet: int aktiv: bool muster: dict extraktion: dict ist_fallback: bool = False schema: str unterordner: Optional[str] freie_ordner: Optional[List[str]] = [] ziel_ordner: Optional[str] = None nur_umbenennen: bool = False ordner_ids: List[int] = [] # IDs der zugeordneten Ordner class Config: from_attributes = True class RegelTestRequest(BaseModel): regel: dict text: str # ============ Verzeichnis-Browser ============ @router.get("/browse") def browse_directory(path: str = "/"): """Listet Verzeichnisse für File-Browser""" import os # Sicherheit: Nur bestimmte Basispfade erlauben allowed_bases = ["/srv", "/home", "/mnt", "/media", "/data", "/tmp"] path = os.path.abspath(path) # Prüfen ob Pfad erlaubt is_allowed = any(path.startswith(base) for base in allowed_bases) or path == "/" if not is_allowed: return {"error": "Pfad nicht erlaubt", "entries": []} if not os.path.exists(path): return {"error": "Pfad existiert nicht", "entries": []} if not os.path.isdir(path): return {"error": "Kein Verzeichnis", "entries": []} try: entries = [] for entry in sorted(os.listdir(path)): full_path = os.path.join(path, entry) if os.path.isdir(full_path): entries.append({ "name": entry, "path": full_path, "type": "directory" }) return { "current": path, "parent": os.path.dirname(path) if path != "/" else None, "entries": entries } except PermissionError: return {"error": "Zugriff verweigert", "entries": []} # ============ BEREICH 1: Postfächer ============ @router.get("/postfaecher", response_model=List[PostfachResponse]) def liste_postfaecher(db: Session = Depends(get_db)): return db.query(Postfach).all() @router.post("/postfaecher", response_model=PostfachResponse) def erstelle_postfach(data: PostfachCreate, db: Session = Depends(get_db)): postfach = Postfach(**data.dict()) db.add(postfach) db.commit() db.refresh(postfach) return postfach @router.put("/postfaecher/{id}", response_model=PostfachResponse) def aktualisiere_postfach(id: int, data: PostfachUpdate, db: Session = Depends(get_db)): postfach = db.query(Postfach).filter(Postfach.id == id).first() if not postfach: raise HTTPException(status_code=404, detail="Nicht gefunden") update_data = data.dict() # Passwort nur aktualisieren wenn nicht leer if not update_data.get("passwort"): del update_data["passwort"] for key, value in update_data.items(): setattr(postfach, key, value) db.commit() db.refresh(postfach) return postfach @router.delete("/postfaecher/{id}") def loesche_postfach(id: int, db: Session = Depends(get_db)): postfach = db.query(Postfach).filter(Postfach.id == id).first() if not postfach: raise HTTPException(status_code=404, detail="Nicht gefunden") db.delete(postfach) db.commit() return {"message": "Gelöscht"} @router.post("/postfaecher/{id}/test") def teste_postfach(id: int, db: Session = Depends(get_db)): postfach = db.query(Postfach).filter(Postfach.id == id).first() if not postfach: raise HTTPException(status_code=404, detail="Nicht gefunden") fetcher = MailFetcher({ "imap_server": postfach.imap_server, "imap_port": postfach.imap_port, "email": postfach.email, "passwort": postfach.passwort, "ordner": postfach.ordner }) return fetcher.test_connection() @router.get("/postfaecher/{id}/abrufen/stream") def rufe_postfach_ab_stream(id: int, db: Session = Depends(get_db)): """Streaming-Endpoint für Mail-Abruf mit Live-Updates""" postfach = db.query(Postfach).filter(Postfach.id == id).first() if not postfach: raise HTTPException(status_code=404, detail="Nicht gefunden") # Daten kopieren für Generator (Session ist nach return nicht mehr verfügbar) pf_data = { "id": postfach.id, "name": postfach.name, "imap_server": postfach.imap_server, "imap_port": postfach.imap_port, "email": postfach.email, "passwort": postfach.passwort, "ordner": postfach.ordner, "alle_ordner": postfach.alle_ordner, "erlaubte_typen": postfach.erlaubte_typen, "max_groesse_mb": postfach.max_groesse_mb, "min_groesse_kb": postfach.min_groesse_kb or 10, "ziel_ordner": postfach.ziel_ordner, "ab_datum": postfach.ab_datum.isoformat() if postfach.ab_datum else None, "groessen_filter": postfach.groessen_filter or {} } # Bereits verarbeitete Message-IDs laden bereits_verarbeitet = set( row.message_id for row in db.query(VerarbeiteteMail.message_id) .filter(VerarbeiteteMail.postfach_id == id) .all() ) def event_generator(): from ..models.database import SessionLocal def send_event(data): return f"data: {json.dumps(data)}\n\n" yield send_event({"type": "start", "postfach": pf_data["name"], "bereits_verarbeitet": len(bereits_verarbeitet)}) # Zielordner erstellen ziel = Path(pf_data["ziel_ordner"]) ziel.mkdir(parents=True, exist_ok=True) fetcher = MailFetcher({ "imap_server": pf_data["imap_server"], "imap_port": pf_data["imap_port"], "email": pf_data["email"], "passwort": pf_data["passwort"], "ordner": pf_data["ordner"], "erlaubte_typen": pf_data["erlaubte_typen"], "max_groesse_mb": pf_data["max_groesse_mb"], "min_groesse_kb": pf_data["min_groesse_kb"], "ab_datum": pf_data["ab_datum"], "groessen_filter": pf_data["groessen_filter"] }) attachments = [] try: # Generator für streaming for event in fetcher.fetch_attachments_generator( ziel, nur_ungelesen=False, alle_ordner=pf_data["alle_ordner"], bereits_verarbeitet=bereits_verarbeitet ): yield send_event(event) if event.get("type") == "datei": attachments.append(event) # DB-Session für Speicherung session = SessionLocal() try: verarbeitete_msg_ids = set() for att in attachments: msg_id = att.get("message_id") if msg_id and msg_id not in verarbeitete_msg_ids: verarbeitete_msg_ids.add(msg_id) session.add(VerarbeiteteMail( postfach_id=pf_data["id"], message_id=msg_id, ordner=att.get("ordner", ""), betreff=att.get("betreff", "")[:500] if att.get("betreff") else None, absender=att.get("absender", "")[:255] if att.get("absender") else None, anzahl_attachments=1 )) # Postfach aktualisieren pf = session.query(Postfach).filter(Postfach.id == pf_data["id"]).first() if pf: pf.letzter_abruf = datetime.utcnow() pf.letzte_anzahl = len(attachments) session.commit() finally: session.close() yield send_event({"type": "fertig", "anzahl": len(attachments)}) except Exception as e: yield send_event({"type": "fehler", "nachricht": str(e)}) finally: fetcher.disconnect() return StreamingResponse( event_generator(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" } ) @router.post("/postfaecher/{id}/abrufen") def rufe_postfach_ab(id: int, db: Session = Depends(get_db)): postfach = db.query(Postfach).filter(Postfach.id == id).first() if not postfach: raise HTTPException(status_code=404, detail="Nicht gefunden") # Bereits verarbeitete Message-IDs laden bereits_verarbeitet = set( row.message_id for row in db.query(VerarbeiteteMail.message_id) .filter(VerarbeiteteMail.postfach_id == id) .all() ) # Zielordner erstellen ziel = Path(postfach.ziel_ordner) ziel.mkdir(parents=True, exist_ok=True) fetcher = MailFetcher({ "imap_server": postfach.imap_server, "imap_port": postfach.imap_port, "email": postfach.email, "passwort": postfach.passwort, "ordner": postfach.ordner, "erlaubte_typen": postfach.erlaubte_typen, "max_groesse_mb": postfach.max_groesse_mb, "min_groesse_kb": postfach.min_groesse_kb or 10 }) try: attachments = fetcher.fetch_attachments( ziel, nur_ungelesen=False, # Alle Mails durchsuchen alle_ordner=postfach.alle_ordner, bereits_verarbeitet=bereits_verarbeitet ) # Verarbeitete Mails in DB speichern verarbeitete_msg_ids = set() for att in attachments: msg_id = att.get("message_id") if msg_id and msg_id not in verarbeitete_msg_ids: verarbeitete_msg_ids.add(msg_id) db.add(VerarbeiteteMail( postfach_id=id, message_id=msg_id, ordner=att.get("ordner", ""), betreff=att.get("betreff", "")[:500] if att.get("betreff") else None, absender=att.get("absender", "")[:255] if att.get("absender") else None, anzahl_attachments=1 )) postfach.letzter_abruf = datetime.utcnow() postfach.letzte_anzahl = len(attachments) db.commit() return { "ergebnisse": [{ "postfach": postfach.name, "anzahl": len(attachments), "dateien": [a["original_name"] for a in attachments], "bereits_verarbeitet": len(bereits_verarbeitet) }] } except Exception as e: return { "ergebnisse": [{ "postfach": postfach.name, "fehler": str(e) }] } finally: fetcher.disconnect() @router.post("/postfaecher/abrufen-alle") def rufe_alle_postfaecher_ab(db: Session = Depends(get_db)): postfaecher = db.query(Postfach).filter(Postfach.aktiv == True).all() ergebnisse = [] for postfach in postfaecher: ziel = Path(postfach.ziel_ordner) ziel.mkdir(parents=True, exist_ok=True) fetcher = MailFetcher({ "imap_server": postfach.imap_server, "imap_port": postfach.imap_port, "email": postfach.email, "passwort": postfach.passwort, "ordner": postfach.ordner, "erlaubte_typen": postfach.erlaubte_typen, "max_groesse_mb": postfach.max_groesse_mb, "min_groesse_kb": postfach.min_groesse_kb or 10 }) try: attachments = fetcher.fetch_attachments(ziel) postfach.letzter_abruf = datetime.utcnow() postfach.letzte_anzahl = len(attachments) ergebnisse.append({ "postfach": postfach.name, "anzahl": len(attachments), "dateien": [a["original_name"] for a in attachments] }) except Exception as e: ergebnisse.append({ "postfach": postfach.name, "fehler": str(e) }) finally: fetcher.disconnect() db.commit() return {"ergebnisse": ergebnisse} @router.get("/postfaecher/abrufen-alle/stream") def rufe_alle_postfaecher_ab_stream(db: Session = Depends(get_db)): """Streaming-Endpoint für Abruf aller Postfächer mit Live-Updates""" postfaecher = db.query(Postfach).filter(Postfach.aktiv == True).all() # Daten kopieren für Generator pf_data_list = [] for postfach in postfaecher: bereits_verarbeitet = set( row.message_id for row in db.query(VerarbeiteteMail.message_id) .filter(VerarbeiteteMail.postfach_id == postfach.id) .all() ) pf_data_list.append({ "id": postfach.id, "name": postfach.name, "imap_server": postfach.imap_server, "imap_port": postfach.imap_port, "email": postfach.email, "passwort": postfach.passwort, "ordner": postfach.ordner, "alle_ordner": postfach.alle_ordner, "erlaubte_typen": postfach.erlaubte_typen, "max_groesse_mb": postfach.max_groesse_mb, "min_groesse_kb": postfach.min_groesse_kb or 10, "ziel_ordner": postfach.ziel_ordner, "ab_datum": postfach.ab_datum.isoformat() if postfach.ab_datum else None, "groessen_filter": postfach.groessen_filter or {}, "bereits_verarbeitet": bereits_verarbeitet }) def event_generator(): from ..models.database import SessionLocal def send_event(data): return f"data: {json.dumps(data)}\n\n" yield send_event({"type": "init", "anzahl_postfaecher": len(pf_data_list)}) for pf_data in pf_data_list: yield send_event({"type": "postfach_start", "name": pf_data["name"], "bereits_verarbeitet": len(pf_data["bereits_verarbeitet"])}) ziel = Path(pf_data["ziel_ordner"]) ziel.mkdir(parents=True, exist_ok=True) fetcher = MailFetcher({ "imap_server": pf_data["imap_server"], "imap_port": pf_data["imap_port"], "email": pf_data["email"], "passwort": pf_data["passwort"], "ordner": pf_data["ordner"], "erlaubte_typen": pf_data["erlaubte_typen"], "max_groesse_mb": pf_data["max_groesse_mb"], "min_groesse_kb": pf_data["min_groesse_kb"], "ab_datum": pf_data["ab_datum"], "groessen_filter": pf_data["groessen_filter"] }) attachments = [] try: for event in fetcher.fetch_attachments_generator( ziel, nur_ungelesen=False, alle_ordner=pf_data["alle_ordner"], bereits_verarbeitet=pf_data["bereits_verarbeitet"] ): # Postfach-Name zum Event hinzufügen event["postfach"] = pf_data["name"] yield send_event(event) if event.get("type") == "datei": attachments.append(event) # Verarbeitete Mails speichern session = SessionLocal() try: verarbeitete_msg_ids = set() for att in attachments: msg_id = att.get("message_id") if msg_id and msg_id not in verarbeitete_msg_ids: verarbeitete_msg_ids.add(msg_id) session.add(VerarbeiteteMail( postfach_id=pf_data["id"], message_id=msg_id, ordner=att.get("ordner"), betreff=att.get("betreff", "")[:500], absender=att.get("absender", "")[:255], anzahl_attachments=1 )) # Postfach Status aktualisieren postfach_obj = session.query(Postfach).filter(Postfach.id == pf_data["id"]).first() if postfach_obj: postfach_obj.letzter_abruf = datetime.utcnow() postfach_obj.letzte_anzahl = len(attachments) session.commit() finally: session.close() yield send_event({"type": "postfach_done", "name": pf_data["name"], "anzahl": len(attachments)}) except Exception as e: yield send_event({"type": "postfach_error", "name": pf_data["name"], "fehler": str(e)}) finally: fetcher.disconnect() yield send_event({"type": "done"}) return StreamingResponse( event_generator(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"} ) # ============ BEREICH 2: Quell-Ordner ============ @router.get("/ordner", response_model=List[OrdnerResponse]) def liste_ordner(db: Session = Depends(get_db)): return db.query(QuellOrdner).all() @router.post("/ordner", response_model=OrdnerResponse) def erstelle_ordner(data: OrdnerCreate, db: Session = Depends(get_db)): ordner = QuellOrdner(**data.dict()) db.add(ordner) db.commit() db.refresh(ordner) return ordner @router.put("/ordner/{id}", response_model=OrdnerResponse) def aktualisiere_ordner(id: int, data: OrdnerCreate, db: Session = Depends(get_db)): ordner = db.query(QuellOrdner).filter(QuellOrdner.id == id).first() if not ordner: raise HTTPException(status_code=404, detail="Nicht gefunden") for key, value in data.dict().items(): setattr(ordner, key, value) db.commit() db.refresh(ordner) return ordner @router.delete("/ordner/{id}") def loesche_ordner(id: int, db: Session = Depends(get_db)): ordner = db.query(QuellOrdner).filter(QuellOrdner.id == id).first() if not ordner: raise HTTPException(status_code=404, detail="Nicht gefunden") db.delete(ordner) db.commit() return {"message": "Gelöscht"} @router.post("/ordner/{id}/aktivieren") def aktiviere_ordner(id: int, db: Session = Depends(get_db)): """Aktiviert/Deaktiviert einen Quellordner""" ordner = db.query(QuellOrdner).filter(QuellOrdner.id == id).first() if not ordner: raise HTTPException(status_code=404, detail="Ordner nicht gefunden") ordner.aktiv = not ordner.aktiv db.commit() return {"message": f"Ordner {'aktiviert' if ordner.aktiv else 'deaktiviert'}", "aktiv": ordner.aktiv} @router.post("/ordner/{id}/kopieren") def kopiere_ordner(id: int, db: Session = Depends(get_db)): """Kopiert eine Grobsortierung (QuellOrdner)""" original = db.query(QuellOrdner).filter(QuellOrdner.id == id).first() if not original: raise HTTPException(status_code=404, detail="Ordner nicht gefunden") # Kopie erstellen kopie = QuellOrdner( name=f"{original.name} (Kopie)", pfad=original.pfad, ziel_ordner=original.ziel_ordner, dateitypen=original.dateitypen.copy() if original.dateitypen else [".pdf"], rekursiv=original.rekursiv, aktiv=False, # Kopie erstmal deaktiviert zugferd_behandlung=original.zugferd_behandlung, signiert_behandlung=original.signiert_behandlung, ocr_aktivieren=original.ocr_aktivieren, original_sichern=original.original_sichern ) db.add(kopie) db.commit() db.refresh(kopie) return {"id": kopie.id, "name": kopie.name, "message": "Grobsortierung kopiert"} @router.get("/ordner/{id}/scannen") def scanne_ordner(id: int, db: Session = Depends(get_db)): """Zeigt Vorschau der Dateien im Ordner (ohne Verarbeitung)""" ordner = db.query(QuellOrdner).filter(QuellOrdner.id == id).first() if not ordner: raise HTTPException(status_code=404, detail="Nicht gefunden") pfad = Path(ordner.pfad) if not pfad.exists(): return {"anzahl": 0, "fehler": "Ordner existiert nicht"} # Dateien sammeln (rekursiv oder nicht) dateien = [] pattern = "**/*" if ordner.rekursiv else "*" for f in pfad.glob(pattern): if f.is_file() and f.suffix.lower() in [t.lower() for t in ordner.dateitypen]: dateien.append(f) return {"anzahl": len(dateien), "dateien": [str(f.relative_to(pfad)) for f in dateien[:30]]} @router.post("/ordner/{id}/verarbeiten") def verarbeite_ordner(id: int, db: Session = Depends(get_db)): """Verarbeitet alle Dateien eines spezifischen Ordners""" quell_ordner = db.query(QuellOrdner).filter(QuellOrdner.id == id).first() if not quell_ordner: raise HTTPException(status_code=404, detail="Nicht gefunden") pfad = Path(quell_ordner.pfad) if not pfad.exists(): return {"fehler": "Ordner existiert nicht", "verarbeitet": []} regeln = db.query(SortierRegel).filter(SortierRegel.aktiv == True).order_by(SortierRegel.prioritaet).all() # Prüfen ob direkt_verschieben aktiv ist hat_direkt_verschieben = getattr(quell_ordner, 'direkt_verschieben', False) if not regeln and not hat_direkt_verschieben: return {"fehler": "Keine Regeln definiert", "verarbeitet": []} # Regeln in Dict-Format regeln_dicts = [] for r in regeln: regeln_dicts.append({ "id": r.id, "name": r.name, "prioritaet": r.prioritaet, "muster": r.muster, "extraktion": r.extraktion, "schema": r.schema, "unterordner": r.unterordner }) sorter = Sorter(regeln_dicts) pdf_processor = PDFProcessor() ergebnis = { "gesamt": 0, "sortiert": 0, "zugferd": 0, "keine_regel": 0, "fehler": 0, "verarbeitet": [] } ziel_basis = Path(quell_ordner.ziel_ordner) dateien = sammle_dateien(quell_ordner) for datei in dateien: ergebnis["gesamt"] += 1 try: rel_pfad = str(datei.relative_to(pfad)) except: rel_pfad = datei.name datei_info = {"original": rel_pfad} try: ist_pdf = datei.suffix.lower() == ".pdf" text = "" ist_zugferd = False ocr_gemacht = False # Nur PDFs durch den PDF-Processor if ist_pdf: pdf_result = pdf_processor.verarbeite(str(datei)) if pdf_result.get("fehler"): raise Exception(pdf_result["fehler"]) text = pdf_result.get("text", "") ist_zugferd = pdf_result.get("ist_zugferd", False) ocr_gemacht = pdf_result.get("ocr_durchgefuehrt", False) # ZUGFeRD behandeln basierend auf Einstellung zugferd_modus = getattr(quell_ordner, 'zugferd_behandlung', 'normal') or 'normal' if ist_zugferd: if zugferd_modus == "ignorieren": # ZUGFeRD überspringen datei_info["status"] = "zugferd_ignoriert" ergebnis["zugferd"] += 1 ergebnis["verarbeitet"].append(datei_info) continue elif zugferd_modus == "separieren": # Original sichern falls konfiguriert (VOR dem Verschieben) original_sichern = getattr(quell_ordner, 'original_sichern', None) if original_sichern: import shutil backup_dir = Path(original_sichern) backup_dir.mkdir(parents=True, exist_ok=True) backup_pfad = backup_dir / datei.name counter = 1 while backup_pfad.exists(): backup_pfad = backup_dir / f"{datei.stem}_{counter}{datei.suffix}" counter += 1 shutil.copy2(str(datei), str(backup_pfad)) datei_info["original_gesichert"] = str(backup_pfad) logger.info(f"ZUGFeRD Original gesichert: {backup_pfad}") # Direkt in Zielordner verschieben (ohne Umbenennung) ziel_basis.mkdir(parents=True, exist_ok=True) neuer_pfad = ziel_basis / datei.name counter = 1 while neuer_pfad.exists(): neuer_pfad = ziel_basis / f"{datei.stem}_{counter}{datei.suffix}" counter += 1 datei.rename(neuer_pfad) ergebnis["zugferd"] += 1 datei_info["zugferd"] = True datei_info["neuer_name"] = neuer_pfad.name datei_info["status"] = "zugferd_verschoben" db.add(VerarbeiteteDatei( original_pfad=str(datei), original_name=datei.name, neuer_pfad=str(neuer_pfad), neuer_name=neuer_pfad.name, ist_zugferd=True, status="zugferd" )) ergebnis["verarbeitet"].append(datei_info) continue # Bei "normal" oder "regel": fortfahren mit Regel-Matching else: # Keine ZUGFeRD-PDF if zugferd_modus == "separieren": # Bei "separieren" NUR ZUGFeRD verarbeiten, normale PDFs ignorieren datei_info["status"] = "kein_zugferd_ignoriert" ergebnis["verarbeitet"].append(datei_info) continue # Direkt verschieben ohne Regel? if hat_direkt_verschieben: # Einfach in Zielordner verschieben ohne Umbenennung ziel_basis.mkdir(parents=True, exist_ok=True) # Original sichern falls konfiguriert original_sichern = getattr(quell_ordner, 'original_sichern', None) if original_sichern: import shutil backup_dir = Path(original_sichern) backup_dir.mkdir(parents=True, exist_ok=True) backup_pfad = backup_dir / datei.name counter = 1 while backup_pfad.exists(): backup_pfad = backup_dir / f"{datei.stem}_{counter}{datei.suffix}" counter += 1 shutil.copy2(str(datei), str(backup_pfad)) datei_info["original_gesichert"] = str(backup_pfad) neuer_pfad = ziel_basis / datei.name counter = 1 while neuer_pfad.exists(): neuer_pfad = ziel_basis / f"{datei.stem}_{counter}{datei.suffix}" counter += 1 datei.rename(neuer_pfad) ergebnis["sortiert"] += 1 datei_info["neuer_name"] = neuer_pfad.name datei_info["status"] = "direkt_verschoben" db.add(VerarbeiteteDatei( original_pfad=str(datei), original_name=datei.name, neuer_pfad=str(neuer_pfad), neuer_name=neuer_pfad.name, ist_zugferd=ist_zugferd, ocr_durchgefuehrt=ocr_gemacht, status="direkt_verschoben" )) ergebnis["verarbeitet"].append(datei_info) continue # Regel finden doc_info = { "text": text, "original_name": datei.name, "absender": "", "dateityp": datei.suffix.lower() } regel = sorter.finde_passende_regel(doc_info) if not regel: datei_info["status"] = "keine_regel" ergebnis["keine_regel"] += 1 ergebnis["verarbeitet"].append(datei_info) continue # Felder extrahieren extrahiert = sorter.extrahiere_felder(regel, doc_info) # Dateiendung beibehalten schema = regel.get("schema", "{datum} - Dokument.pdf") if schema.endswith(".pdf"): schema = schema[:-4] + datei.suffix neuer_name = sorter.generiere_dateinamen({"schema": schema, **regel}, extrahiert) # Zielordner ziel = ziel_basis if regel.get("unterordner"): ziel = ziel / regel["unterordner"] ziel.mkdir(parents=True, exist_ok=True) # Original sichern falls konfiguriert (VOR dem Verschieben) original_sichern = getattr(quell_ordner, 'original_sichern', None) if original_sichern: import shutil backup_dir = Path(original_sichern) backup_dir.mkdir(parents=True, exist_ok=True) backup_pfad = backup_dir / datei.name counter = 1 while backup_pfad.exists(): backup_pfad = backup_dir / f"{datei.stem}_{counter}{datei.suffix}" counter += 1 shutil.copy2(str(datei), str(backup_pfad)) datei_info["original_gesichert"] = str(backup_pfad) logger.info(f"Original gesichert: {backup_pfad}") # Verschieben neuer_pfad = sorter.verschiebe_datei(str(datei), str(ziel), neuer_name) ergebnis["sortiert"] += 1 datei_info["neuer_name"] = neuer_name datei_info["regel"] = regel.get("name") if ist_zugferd: datei_info["zugferd"] = True ergebnis["zugferd"] += 1 db.add(VerarbeiteteDatei( original_pfad=str(datei), original_name=datei.name, neuer_pfad=neuer_pfad, neuer_name=neuer_name, ist_zugferd=ist_zugferd, ocr_durchgefuehrt=ocr_gemacht, status="sortiert", extrahierte_daten=extrahiert )) except Exception as e: ergebnis["fehler"] += 1 datei_info["fehler"] = str(e) ergebnis["verarbeitet"].append(datei_info) db.commit() return ergebnis @router.get("/grobsortierung/stream") async def grobsortierung_stream(db: Session = Depends(get_db)): """Streaming-Endpoint für Grobsortierung aller aktiven Ordner mit Live-Updates""" from ..models.database import SessionLocal # Aktive Ordner vorab laden ordner_liste = db.query(QuellOrdner).filter(QuellOrdner.aktiv == True).all() ordner_daten = [{ "id": o.id, "name": o.name, "pfad": o.pfad, "ziel_ordner": o.ziel_ordner, "dateitypen": o.dateitypen, "rekursiv": o.rekursiv, "direkt_verschieben": getattr(o, 'direkt_verschieben', False), "zugferd_behandlung": getattr(o, 'zugferd_behandlung', 'separieren'), "ocr_aktivieren": getattr(o, 'ocr_aktivieren', True), "original_sichern": getattr(o, 'original_sichern', None) } for o in ordner_liste] # Regeln vorab laden regeln = db.query(SortierRegel).filter(SortierRegel.aktiv == True).order_by(SortierRegel.prioritaet).all() regeln_dicts = [{ "id": r.id, "name": r.name, "prioritaet": r.prioritaet, "muster": r.muster, "extraktion": r.extraktion, "schema": r.schema, "unterordner": r.unterordner } for r in regeln] async def event_generator(): def send_event(data): return f"data: {json.dumps(data)}\n\n" if not ordner_daten: yield send_event({"type": "fehler", "nachricht": "Keine aktiven Ordner konfiguriert"}) return pdf_processor = PDFProcessor() sorter = Sorter(regeln_dicts) if regeln_dicts else None gesamt_stats = {"gesamt": 0, "sortiert": 0, "zugferd": 0, "fehler": 0} # Dateien zählen total_dateien = 0 for o in ordner_daten: pfad = Path(o["pfad"]) if pfad.exists(): dateien = sammle_dateien_aus_pfad(str(pfad), o["dateitypen"], o["rekursiv"]) total_dateien += len(dateien) yield send_event({"type": "start", "ordner_count": len(ordner_daten), "gesamt": total_dateien}) await asyncio.sleep(0) session = SessionLocal() try: for quell_ordner in ordner_daten: pfad = Path(quell_ordner["pfad"]) if not pfad.exists(): yield send_event({"type": "ordner_fehler", "ordner": quell_ordner["name"], "fehler": "Ordner existiert nicht"}) await asyncio.sleep(0) continue ziel_basis = Path(quell_ordner["ziel_ordner"]) dateien = sammle_dateien_aus_pfad(str(pfad), quell_ordner["dateitypen"], quell_ordner["rekursiv"]) yield send_event({ "type": "ordner", "ordner": quell_ordner["name"], "dateien": len(dateien) }) await asyncio.sleep(0) for datei in dateien: gesamt_stats["gesamt"] += 1 datei_info = {"original": datei.name, "ordner": quell_ordner["name"]} try: ist_pdf = datei.suffix.lower() == ".pdf" text = "" ist_zugferd = False ocr_gemacht = False # PDF verarbeiten if ist_pdf: pdf_result = pdf_processor.verarbeite( str(datei), ocr_erlaubt=quell_ordner.get("ocr_aktivieren", True), original_backup_pfad=quell_ordner.get("original_sichern") ) if pdf_result.get("fehler"): raise Exception(pdf_result["fehler"]) text = pdf_result.get("text", "") ist_zugferd = pdf_result.get("ist_zugferd", False) ocr_gemacht = pdf_result.get("ocr_durchgefuehrt", False) # ZUGFeRD-Behandlung prüfen zugferd_behandlung = quell_ordner.get("zugferd_behandlung", "normal") if zugferd_behandlung == "separieren": if not ist_zugferd: # Nur ZUGFeRD-PDFs verarbeiten, Rest überspringen datei_info["status"] = "kein_zugferd_ignoriert" yield send_event({"type": "datei_fertig", **datei_info}) await asyncio.sleep(0) continue # ZUGFeRD-PDF direkt in Zielordner verschieben ziel_basis.mkdir(parents=True, exist_ok=True) neuer_pfad = ziel_basis / datei.name counter = 1 while neuer_pfad.exists(): neuer_pfad = ziel_basis / f"{datei.stem}_{counter}{datei.suffix}" counter += 1 datei.rename(neuer_pfad) gesamt_stats["sortiert"] += 1 gesamt_stats["zugferd"] += 1 datei_info["neuer_name"] = neuer_pfad.name datei_info["status"] = "zugferd_verschoben" datei_info["zugferd"] = True session.add(VerarbeiteteDatei( original_pfad=str(datei), original_name=datei.name, neuer_pfad=str(neuer_pfad), neuer_name=neuer_pfad.name, ist_zugferd=True, ocr_durchgefuehrt=ocr_gemacht, status="zugferd" )) yield send_event({"type": "datei_fertig", **datei_info}) await asyncio.sleep(0) continue elif zugferd_behandlung == "ignorieren" and ist_zugferd: # ZUGFeRD-PDFs überspringen datei_info["status"] = "zugferd_ignoriert" gesamt_stats["zugferd"] += 1 yield send_event({"type": "datei_fertig", **datei_info}) await asyncio.sleep(0) continue # Bei "normal" oder "regel": weiter mit Regeln/direkt_verschieben # Direkt verschieben? if quell_ordner["direkt_verschieben"]: ziel_basis.mkdir(parents=True, exist_ok=True) # Original sichern if quell_ordner["original_sichern"]: import shutil backup_dir = Path(quell_ordner["original_sichern"]) backup_dir.mkdir(parents=True, exist_ok=True) backup_pfad = backup_dir / datei.name counter = 1 while backup_pfad.exists(): backup_pfad = backup_dir / f"{datei.stem}_{counter}{datei.suffix}" counter += 1 shutil.copy2(str(datei), str(backup_pfad)) neuer_pfad = ziel_basis / datei.name counter = 1 while neuer_pfad.exists(): neuer_pfad = ziel_basis / f"{datei.stem}_{counter}{datei.suffix}" counter += 1 datei.rename(neuer_pfad) gesamt_stats["sortiert"] += 1 datei_info["neuer_name"] = neuer_pfad.name datei_info["status"] = "direkt_verschoben" session.add(VerarbeiteteDatei( original_pfad=str(datei), original_name=datei.name, neuer_pfad=str(neuer_pfad), neuer_name=neuer_pfad.name, ist_zugferd=ist_zugferd, ocr_durchgefuehrt=ocr_gemacht, status="direkt_verschoben" )) yield send_event({"type": "datei_fertig", **datei_info}) await asyncio.sleep(0) continue # Mit Regeln sortieren if sorter: doc_info = { "text": text, "original_name": datei.name, "absender": "", "dateityp": datei.suffix.lower() } regel = sorter.finde_passende_regel(doc_info) if regel: extrahiert = sorter.extrahiere_felder(regel, doc_info) schema = regel.get("schema", "{datum} - Dokument.pdf") if schema.endswith(".pdf"): schema = schema[:-4] + datei.suffix neuer_name = sorter.generiere_dateinamen({"schema": schema, **regel}, extrahiert) ziel = ziel_basis if regel.get("unterordner"): ziel = ziel / regel["unterordner"] ziel.mkdir(parents=True, exist_ok=True) neuer_pfad = sorter.verschiebe_datei(str(datei), str(ziel), neuer_name) gesamt_stats["sortiert"] += 1 datei_info["neuer_name"] = neuer_name datei_info["regel"] = regel.get("name") session.add(VerarbeiteteDatei( original_pfad=str(datei), original_name=datei.name, neuer_pfad=neuer_pfad, neuer_name=neuer_name, ist_zugferd=ist_zugferd, ocr_durchgefuehrt=ocr_gemacht, status="sortiert", extrahierte_daten=extrahiert )) yield send_event({"type": "datei_fertig", **datei_info}) await asyncio.sleep(0) continue # Keine Regel gefunden datei_info["status"] = "keine_regel" yield send_event({"type": "datei_keine_regel", **datei_info}) await asyncio.sleep(0) except Exception as e: gesamt_stats["fehler"] += 1 datei_info["fehler"] = str(e) yield send_event({"type": "datei_fehler", **datei_info}) await asyncio.sleep(0) session.commit() finally: session.close() yield send_event({"type": "fertig", **gesamt_stats}) return StreamingResponse( event_generator(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" } ) # ============ Regeln ============ @router.get("/regeln") def liste_regeln(db: Session = Depends(get_db)): """Gibt alle Regeln mit ihren zugeordneten Ordner-IDs zurück""" regeln = db.query(SortierRegel).order_by(SortierRegel.prioritaet).all() # Alle Ordner-Zuweisungen auf einmal laden alle_zuweisungen = db.query(OrdnerRegel).all() regel_ordner_map = {} for z in alle_zuweisungen: if z.regel_id not in regel_ordner_map: regel_ordner_map[z.regel_id] = [] regel_ordner_map[z.regel_id].append(z.ordner_id) # Regeln mit Ordner-IDs anreichern result = [] for r in regeln: regel_dict = { "id": r.id, "name": r.name, "prioritaet": r.prioritaet, "aktiv": r.aktiv, "muster": r.muster or {}, "extraktion": r.extraktion or {}, "ist_fallback": r.ist_fallback, "schema": r.schema, "unterordner": r.unterordner, "freie_ordner": r.freie_ordner or [], "ziel_ordner": r.ziel_ordner, "nur_umbenennen": r.nur_umbenennen, "ordner_ids": regel_ordner_map.get(r.id, []) } result.append(regel_dict) return result @router.post("/regeln", response_model=RegelResponse) def erstelle_regel(data: RegelCreate, db: Session = Depends(get_db)): regel = SortierRegel(**data.dict()) db.add(regel) db.commit() db.refresh(regel) return regel @router.put("/regeln/{id}", response_model=RegelResponse) def aktualisiere_regel(id: int, data: RegelCreate, db: Session = Depends(get_db)): regel = db.query(SortierRegel).filter(SortierRegel.id == id).first() if not regel: raise HTTPException(status_code=404, detail="Nicht gefunden") for key, value in data.dict().items(): setattr(regel, key, value) db.commit() db.refresh(regel) return regel @router.delete("/regeln/{id}") def loesche_regel(id: int, db: Session = Depends(get_db)): regel = db.query(SortierRegel).filter(SortierRegel.id == id).first() if not regel: raise HTTPException(status_code=404, detail="Nicht gefunden") db.delete(regel) db.commit() return {"message": "Gelöscht"} @router.post("/regeln/{id}/aktivieren") def aktiviere_regel(id: int, db: Session = Depends(get_db)): """Aktiviert/Deaktiviert eine Sortierregel""" regel = db.query(SortierRegel).filter(SortierRegel.id == id).first() if not regel: raise HTTPException(status_code=404, detail="Regel nicht gefunden") regel.aktiv = not regel.aktiv db.commit() return {"message": f"Regel {'aktiviert' if regel.aktiv else 'deaktiviert'}", "aktiv": regel.aktiv} @router.post("/regeln/{id}/kopieren") def kopiere_regel(id: int, db: Session = Depends(get_db)): """Kopiert eine Sortierregel""" original = db.query(SortierRegel).filter(SortierRegel.id == id).first() if not original: raise HTTPException(status_code=404, detail="Regel nicht gefunden") # Kopie erstellen kopie = SortierRegel( name=f"{original.name} (Kopie)", prioritaet=original.prioritaet, aktiv=False, # Kopie erstmal deaktiviert muster=original.muster.copy() if original.muster else {}, extraktion=original.extraktion.copy() if original.extraktion else {}, schema=original.schema, unterordner=original.unterordner, ist_fallback=original.ist_fallback, freie_ordner=original.freie_ordner.copy() if original.freie_ordner else [], ziel_ordner=original.ziel_ordner, nur_umbenennen=original.nur_umbenennen ) db.add(kopie) db.commit() db.refresh(kopie) return {"id": kopie.id, "name": kopie.name, "message": "Regel kopiert"} @router.post("/regeln/test") def teste_regel(data: RegelTestRequest): regel = data.regel regel["aktiv"] = True regel["prioritaet"] = 1 sorter = Sorter([regel]) doc_info = {"text": data.text, "original_name": "test.pdf", "absender": ""} passend = sorter.finde_passende_regel(doc_info) if passend: extrahiert = sorter.extrahiere_felder(passend, doc_info) dateiname = sorter.generiere_dateinamen(passend, extrahiert) return {"passt": True, "extrahiert": extrahiert, "dateiname": dateiname} return {"passt": False} @router.get("/regeln/export") def export_regeln(db: Session = Depends(get_db)): """Exportiert alle Regeln als JSON""" regeln = db.query(SortierRegel).order_by(SortierRegel.prioritaet).all() export_data = [] for r in regeln: export_data.append({ "name": r.name, "prioritaet": r.prioritaet, "muster": r.muster, "extraktion": r.extraktion, "schema": r.schema, "unterordner": r.unterordner, "ziel_ordner": r.ziel_ordner, "nur_umbenennen": r.nur_umbenennen, "ist_fallback": r.ist_fallback, "aktiv": r.aktiv }) return {"regeln": export_data, "anzahl": len(export_data)} @router.get("/regeln/{id}/export") def export_einzelne_regel(id: int, db: Session = Depends(get_db)): """Exportiert eine einzelne Regel als JSON""" regel = db.query(SortierRegel).filter(SortierRegel.id == id).first() if not regel: raise HTTPException(status_code=404, detail="Regel nicht gefunden") export_data = { "name": regel.name, "prioritaet": regel.prioritaet, "muster": regel.muster, "extraktion": regel.extraktion, "schema": regel.schema, "unterordner": regel.unterordner, "ziel_ordner": regel.ziel_ordner, "nur_umbenennen": regel.nur_umbenennen, "ist_fallback": regel.ist_fallback, "aktiv": regel.aktiv } return {"regeln": [export_data], "anzahl": 1, "regel_name": regel.name} class RegelnImportRequest(BaseModel): regeln: List[dict] modus: str = "hinzufuegen" # "hinzufuegen", "ersetzen", "aktualisieren" @router.post("/regeln/import") def import_regeln(data: RegelnImportRequest, db: Session = Depends(get_db)): """Importiert Regeln aus JSON""" importiert = 0 aktualisiert = 0 uebersprungen = 0 if data.modus == "ersetzen": # Alle bestehenden Regeln löschen db.query(OrdnerRegel).delete() db.query(SortierRegel).delete() for regel_data in data.regeln: name = regel_data.get("name") if not name: uebersprungen += 1 continue # Prüfen ob Regel mit diesem Namen existiert existiert = db.query(SortierRegel).filter(SortierRegel.name == name).first() if existiert and data.modus == "hinzufuegen": uebersprungen += 1 continue if existiert and data.modus == "aktualisieren": # Bestehende Regel aktualisieren existiert.prioritaet = regel_data.get("prioritaet", 100) existiert.muster = regel_data.get("muster", {}) existiert.extraktion = regel_data.get("extraktion", {}) existiert.schema = regel_data.get("schema", "{datum} - Dokument.pdf") existiert.unterordner = regel_data.get("unterordner") existiert.ziel_ordner = regel_data.get("ziel_ordner") existiert.nur_umbenennen = regel_data.get("nur_umbenennen", False) existiert.ist_fallback = regel_data.get("ist_fallback", False) existiert.aktiv = regel_data.get("aktiv", True) aktualisiert += 1 else: # Neue Regel erstellen neue_regel = SortierRegel( name=name, prioritaet=regel_data.get("prioritaet", 100), muster=regel_data.get("muster", {}), extraktion=regel_data.get("extraktion", {}), schema=regel_data.get("schema", "{datum} - Dokument.pdf"), unterordner=regel_data.get("unterordner"), ziel_ordner=regel_data.get("ziel_ordner"), nur_umbenennen=regel_data.get("nur_umbenennen", False), ist_fallback=regel_data.get("ist_fallback", False), aktiv=regel_data.get("aktiv", True) ) db.add(neue_regel) importiert += 1 db.commit() return { "importiert": importiert, "aktualisiert": aktualisiert, "uebersprungen": uebersprungen, "gesamt": len(data.regeln) } # ============ Ordner-Regel-Zuweisungen ============ @router.get("/ordner/{ordner_id}/regeln") def get_ordner_regeln(ordner_id: int, db: Session = Depends(get_db)): """Gibt alle Regeln zurück die diesem Ordner zugewiesen sind""" zuweisungen = db.query(OrdnerRegel).filter(OrdnerRegel.ordner_id == ordner_id).all() regel_ids = [z.regel_id for z in zuweisungen] return {"regel_ids": regel_ids} @router.post("/ordner/{ordner_id}/regeln/{regel_id}") def ordner_regel_zuweisen(ordner_id: int, regel_id: int, db: Session = Depends(get_db)): """Weist eine Regel einem Ordner zu""" # Prüfe ob Ordner und Regel existieren ordner = db.query(QuellOrdner).filter(QuellOrdner.id == ordner_id).first() regel = db.query(SortierRegel).filter(SortierRegel.id == regel_id).first() if not ordner: raise HTTPException(status_code=404, detail="Ordner nicht gefunden") if not regel: raise HTTPException(status_code=404, detail="Regel nicht gefunden") # Prüfe ob Zuweisung bereits existiert existiert = db.query(OrdnerRegel).filter( OrdnerRegel.ordner_id == ordner_id, OrdnerRegel.regel_id == regel_id ).first() if existiert: return {"message": "Bereits zugewiesen"} # Neue Zuweisung erstellen zuweisung = OrdnerRegel(ordner_id=ordner_id, regel_id=regel_id) db.add(zuweisung) db.commit() return {"message": "Zugewiesen"} @router.delete("/ordner/{ordner_id}/regeln/{regel_id}") def ordner_regel_entfernen(ordner_id: int, regel_id: int, db: Session = Depends(get_db)): """Entfernt eine Regel-Zuweisung von einem Ordner""" zuweisung = db.query(OrdnerRegel).filter( OrdnerRegel.ordner_id == ordner_id, OrdnerRegel.regel_id == regel_id ).first() if not zuweisung: raise HTTPException(status_code=404, detail="Zuweisung nicht gefunden") db.delete(zuweisung) db.commit() return {"message": "Entfernt"} @router.get("/regeln/{regel_id}/ordner") def get_regel_ordner(regel_id: int, db: Session = Depends(get_db)): """Gibt alle Ordner zurück denen diese Regel zugewiesen ist""" # Zugewiesene Quell-Ordner (über OrdnerRegel) zuweisungen = db.query(OrdnerRegel).filter(OrdnerRegel.regel_id == regel_id).all() ordner_ids = [z.ordner_id for z in zuweisungen] # Freie Ordner aus der Regel selbst regel = db.query(SortierRegel).filter(SortierRegel.id == regel_id).first() freie_ordner = regel.freie_ordner if regel and regel.freie_ordner else [] return {"ordner_ids": ordner_ids, "freie_ordner": freie_ordner} class OrdnerZuweisungRequest(BaseModel): ordner_ids: List[int] = [] freie_ordner: List[str] = [] @router.put("/regeln/{regel_id}/ordner") def set_regel_ordner(regel_id: int, data: OrdnerZuweisungRequest, db: Session = Depends(get_db)): """Setzt alle Ordner-Zuweisungen für eine Regel (ersetzt bestehende)""" # Prüfe ob Regel existiert regel = db.query(SortierRegel).filter(SortierRegel.id == regel_id).first() if not regel: raise HTTPException(status_code=404, detail="Regel nicht gefunden") # Lösche alle bestehenden Zuweisungen db.query(OrdnerRegel).filter(OrdnerRegel.regel_id == regel_id).delete() # Erstelle neue Zuweisungen for ordner_id in data.ordner_ids: ordner = db.query(QuellOrdner).filter(QuellOrdner.id == ordner_id).first() if ordner: zuweisung = OrdnerRegel(ordner_id=ordner_id, regel_id=regel_id) db.add(zuweisung) # Freie Ordner in der Regel speichern regel.freie_ordner = data.freie_ordner db.commit() return {"message": f"{len(data.ordner_ids)} Ordner + {len(data.freie_ordner)} freie Ordner zugewiesen"} # ============ Sortierung ============ def sammle_dateien(ordner: QuellOrdner) -> list: """Sammelt alle Dateien aus einem Ordner (rekursiv oder nicht)""" pfad = Path(ordner.pfad) if not pfad.exists(): return [] dateien = [] pattern = "**/*" if ordner.rekursiv else "*" erlaubte = [t.lower() for t in (ordner.dateitypen or [".pdf"])] # Wenn ZUGFeRD oder Signiert aktiviert, PDFs auch sammeln (für Erkennung) zugferd_aktiv = getattr(ordner, 'zugferd_behandlung', 'normal') == 'separieren' signiert_aktiv = getattr(ordner, 'signiert_behandlung', 'normal') == 'separieren' pdf_fuer_erkennung = (zugferd_aktiv or signiert_aktiv) and ".pdf" not in erlaubte for f in pfad.glob(pattern): if f.is_file(): suffix = f.suffix.lower() if suffix in erlaubte: dateien.append(f) elif pdf_fuer_erkennung and suffix == ".pdf": # PDF nur für ZUGFeRD/Signiert-Erkennung hinzufügen dateien.append(f) return dateien def sammle_dateien_aus_pfad(pfad_str: str, erlaubte_typen: List[str] = None, rekursiv: bool = True) -> list: """Sammelt alle Dateien aus einem freien Ordner-Pfad""" pfad = Path(pfad_str) if not pfad.exists() or not pfad.is_dir(): return [] dateien = [] pattern = "**/*" if rekursiv else "*" erlaubte = [t.lower() for t in (erlaubte_typen or [".pdf"])] for f in pfad.glob(pattern): if f.is_file() and f.suffix.lower() in erlaubte: dateien.append(f) return dateien @router.post("/sortierung/starten") def starte_sortierung(db: Session = Depends(get_db)): ordner_liste = db.query(QuellOrdner).filter(QuellOrdner.aktiv == True).all() if not ordner_liste: return {"fehler": "Keine Quell-Ordner konfiguriert", "verarbeitet": []} pdf_processor = PDFProcessor() ergebnis = { "gesamt": 0, "sortiert": 0, "zugferd": 0, "fehler": 0, "verarbeitet": [] } # Fallback-Regeln laden (gelten für alle Ordner wenn keine andere Regel passt) fallback_regeln = db.query(SortierRegel).filter( SortierRegel.aktiv == True, SortierRegel.ist_fallback == True ).order_by(SortierRegel.prioritaet).all() fallback_dicts = [{ "id": r.id, "name": r.name, "prioritaet": r.prioritaet, "muster": r.muster, "extraktion": r.extraktion, "schema": r.schema, "unterordner": r.unterordner, "ziel_ordner": getattr(r, 'ziel_ordner', None), "nur_umbenennen": getattr(r, 'nur_umbenennen', False) } for r in fallback_regeln] for quell_ordner in ordner_liste: # Sortierregeln suchen im ZIEL-Ordner (wo Dateien nach Grobsortierung liegen) pfad = Path(quell_ordner.ziel_ordner) if not pfad.exists(): continue dateien = sammle_dateien_aus_pfad(str(pfad), [".pdf"], rekursiv=True) # Lade nur Regeln die diesem Ordner zugewiesen sind zuweisungen = db.query(OrdnerRegel).filter(OrdnerRegel.ordner_id == quell_ordner.id).all() zugewiesene_regel_ids = [z.regel_id for z in zuweisungen] if zugewiesene_regel_ids: regeln = db.query(SortierRegel).filter( SortierRegel.id.in_(zugewiesene_regel_ids), SortierRegel.aktiv == True, SortierRegel.ist_fallback == False # Fallbacks separat behandeln ).order_by(SortierRegel.prioritaet).all() else: regeln = [] # Regeln in Dict-Format regeln_dicts = [{ "id": r.id, "name": r.name, "prioritaet": r.prioritaet, "muster": r.muster, "extraktion": r.extraktion, "schema": r.schema, "unterordner": r.unterordner, "ziel_ordner": getattr(r, 'ziel_ordner', None), "nur_umbenennen": getattr(r, 'nur_umbenennen', False) } for r in regeln] # Sorter mit Ordner-spezifischen Regeln sorter = Sorter(regeln_dicts) if regeln_dicts else None fallback_sorter = Sorter(fallback_dicts) if fallback_dicts else None print(f"=== Ordner '{quell_ordner.name}': {len(dateien)} Dateien, {len(regeln_dicts)} zugewiesene Regeln, {len(fallback_dicts)} Fallback-Regeln ===", flush=True) for r in regeln_dicts: print(f" Regel: {r.get('name')} - Muster: {r.get('muster')} - Schema: {r.get('schema')}", flush=True) for datei in dateien: ergebnis["gesamt"] += 1 try: rel_pfad = str(datei.relative_to(pfad)) except: rel_pfad = datei.name datei_info = {"original": rel_pfad, "ordner": quell_ordner.name} try: ist_pdf = datei.suffix.lower() == ".pdf" text = "" ist_zugferd = False ocr_gemacht = False # Nur PDFs durch den PDF-Processor if ist_pdf: # OCR-Optionen aus Ordner-Einstellungen ocr_erlaubt = getattr(quell_ordner, 'ocr_aktivieren', True) original_backup = getattr(quell_ordner, 'original_sichern', None) pdf_result = pdf_processor.verarbeite( str(datei), ocr_erlaubt=ocr_erlaubt, original_backup_pfad=original_backup ) if pdf_result.get("fehler"): raise Exception(pdf_result["fehler"]) text = pdf_result.get("text", "") ist_zugferd = pdf_result.get("ist_zugferd", False) ocr_gemacht = pdf_result.get("ocr_durchgefuehrt", False) # Info über Original-Sicherung if pdf_result.get("original_gesichert"): datei_info["original_gesichert"] = pdf_result["original_gesichert"] # ZUGFeRD-Info für Anzeige merken if ist_zugferd: datei_info["zugferd"] = True # Signierte PDF-Behandlung ist_signiert = pdf_result.get("ist_signiert", False) if ist_signiert: signiert_behandlung = getattr(quell_ordner, 'signiert_behandlung', 'normal') if signiert_behandlung == "separieren": # Signierte PDFs bleiben im gleichen Ordner pfad.mkdir(parents=True, exist_ok=True) neuer_pfad = pfad / datei.name counter = 1 while neuer_pfad.exists(): neuer_pfad = pfad / f"{datei.stem}_{counter}{datei.suffix}" counter += 1 datei.rename(neuer_pfad) ergebnis["sortiert"] += 1 datei_info["signiert"] = True datei_info["neuer_name"] = neuer_pfad.name db.add(VerarbeiteteDatei( original_pfad=str(datei), original_name=datei.name, neuer_pfad=str(neuer_pfad), neuer_name=neuer_pfad.name, status="signiert" )) ergebnis["verarbeitet"].append(datei_info) continue elif signiert_behandlung == "ignorieren": datei_info["uebersprungen"] = "Signierte PDF ignoriert" ergebnis["verarbeitet"].append(datei_info) continue # Bei "normal": weiter mit Regel-Matching # Prüfe ob PDF nur für ZUGFeRD/Signiert-Erkennung gescannt wurde # Wenn ja und keine besondere Dateiart erkannt: überspringen erlaubte_typen = [t.lower() for t in (quell_ordner.dateitypen or [".pdf"])] if ist_pdf and ".pdf" not in erlaubte_typen: # PDF war nur für ZUGFeRD/Signiert-Erkennung, aber keine erkannt datei_info["uebersprungen"] = "Kein ZUGFeRD/Signiert - PDF nicht in Dateitypen" ergebnis["verarbeitet"].append(datei_info) continue # Dokument-Info für Regel-Matching doc_info = { "text": text, "original_name": datei.name, "absender": "", "dateityp": datei.suffix.lower() } # Passende Regel finden (erst zugewiesene, dann Fallback) regel = None if sorter: regel = sorter.finde_passende_regel(doc_info) # Fallback-Regel wenn keine zugewiesene passt if not regel and fallback_sorter: regel = fallback_sorter.finde_passende_regel(doc_info) if regel: datei_info["fallback"] = True if not regel: print(f"!!! Keine passende Regel für {datei.name}", flush=True) datei_info["fehler"] = "Keine passende Regel (keine Regeln zugewiesen)" ergebnis["fehler"] += 1 ergebnis["verarbeitet"].append(datei_info) continue print(f">>> Regel '{regel.get('name')}' passt für {datei.name}", flush=True) # Felder extrahieren extrahiert = sorter.extrahiere_felder(regel, doc_info) if sorter else \ fallback_sorter.extrahiere_felder(regel, doc_info) # Dateiendung beibehalten schema = regel.get("schema", "{datum} - Dokument.pdf") if schema.endswith(".pdf"): schema = schema[:-4] + datei.suffix neuer_name = (sorter or fallback_sorter).generiere_dateinamen( {"schema": schema, **regel}, extrahiert ) # Zielordner bestimmen if regel.get("nur_umbenennen"): # Nur umbenennen - Datei bleibt im aktuellen Ordner ziel = datei.parent elif regel.get("ziel_ordner"): # Regel hat eigenen Zielordner ziel = Path(regel["ziel_ordner"]) if regel.get("unterordner"): ziel = ziel / regel["unterordner"] else: # Kein Zielordner - bleibt im gleichen Ordner ziel = pfad if regel.get("unterordner"): ziel = ziel / regel["unterordner"] ziel.mkdir(parents=True, exist_ok=True) # Verschieben print(f">>> Verschiebe: {datei.name} -> {ziel}/{neuer_name}", flush=True) print(f" Extrahierte Felder: {extrahiert}", flush=True) neuer_pfad = (sorter or fallback_sorter).verschiebe_datei(str(datei), str(ziel), neuer_name) ergebnis["sortiert"] += 1 datei_info["neuer_name"] = neuer_name datei_info["regel"] = regel.get("name", "Unbekannt") db.add(VerarbeiteteDatei( original_pfad=str(datei), original_name=datei.name, neuer_pfad=neuer_pfad, neuer_name=neuer_name, ist_zugferd=False, ocr_durchgefuehrt=ocr_gemacht, status="sortiert", extrahierte_daten=extrahiert )) except Exception as e: ergebnis["fehler"] += 1 datei_info["fehler"] = str(e) ergebnis["verarbeitet"].append(datei_info) # ============ Freie Ordner verarbeiten ============ # Lade alle Regeln mit freien Ordnern alle_regeln = db.query(SortierRegel).filter( SortierRegel.aktiv == True ).all() for regel in alle_regeln: freie_ordner = regel.freie_ordner if regel.freie_ordner else [] if not freie_ordner: continue regel_dict = { "id": regel.id, "name": regel.name, "prioritaet": regel.prioritaet, "muster": regel.muster, "extraktion": regel.extraktion, "schema": regel.schema, "unterordner": regel.unterordner, "ziel_ordner": getattr(regel, 'ziel_ordner', None), "nur_umbenennen": getattr(regel, 'nur_umbenennen', False) } regel_sorter = Sorter([regel_dict]) for freier_ordner_pfad in freie_ordner: freier_pfad = Path(freier_ordner_pfad) if not freier_pfad.exists() or not freier_pfad.is_dir(): continue # Zielordner = der freie Ordner selbst (Dateien werden darin umbenannt) # ODER wir brauchen einen separaten Zielordner? # Vorerst: Dateien werden im gleichen Ordner umbenannt (in-place) dateien = sammle_dateien_aus_pfad(freier_ordner_pfad, [".pdf"]) for datei in dateien: ergebnis["gesamt"] += 1 datei_info = {"original": datei.name, "ordner": f"Freier Ordner: {freier_ordner_pfad}"} try: ist_pdf = datei.suffix.lower() == ".pdf" text = "" ist_zugferd = False ocr_gemacht = False if ist_pdf: pdf_result = pdf_processor.verarbeite(str(datei)) if pdf_result.get("fehler"): raise Exception(pdf_result["fehler"]) text = pdf_result.get("text", "") ist_zugferd = pdf_result.get("ist_zugferd", False) ocr_gemacht = pdf_result.get("ocr_durchgefuehrt", False) doc_info = { "text": text, "original_name": datei.name, "absender": "", "dateityp": datei.suffix.lower() } # Prüfe ob Regel passt passend = regel_sorter.finde_passende_regel(doc_info) if not passend: datei_info["uebersprungen"] = "Regel passt nicht" ergebnis["verarbeitet"].append(datei_info) continue # Felder extrahieren extrahiert = regel_sorter.extrahiere_felder(passend, doc_info) # Dateiname generieren schema = passend.get("schema", "{datum} - Dokument.pdf") if schema.endswith(".pdf"): schema = schema[:-4] + datei.suffix neuer_name = regel_sorter.generiere_dateinamen( {"schema": schema, **passend}, extrahiert ) # Zielordner bestimmen if passend.get("nur_umbenennen"): # Nur umbenennen - Datei bleibt im aktuellen Ordner ziel = datei.parent elif passend.get("ziel_ordner"): # Regel hat eigenen Zielordner ziel = Path(passend["ziel_ordner"]) if passend.get("unterordner"): ziel = ziel / passend["unterordner"] else: # Kein Zielordner - bleibt im freien Ordner ziel = freier_pfad if passend.get("unterordner"): ziel = ziel / passend["unterordner"] ziel.mkdir(parents=True, exist_ok=True) # Verschieben/Umbenennen neuer_pfad = regel_sorter.verschiebe_datei(str(datei), str(ziel), neuer_name) ergebnis["sortiert"] += 1 datei_info["neuer_name"] = neuer_name datei_info["regel"] = passend.get("name", "Unbekannt") if ist_zugferd: datei_info["zugferd"] = True db.add(VerarbeiteteDatei( original_pfad=str(datei), original_name=datei.name, neuer_pfad=neuer_pfad, neuer_name=neuer_name, ist_zugferd=ist_zugferd, ocr_durchgefuehrt=ocr_gemacht, status="sortiert", extrahierte_daten=extrahiert )) except Exception as e: ergebnis["fehler"] += 1 datei_info["fehler"] = str(e) ergebnis["verarbeitet"].append(datei_info) db.commit() return ergebnis @router.get("/sortierung/starten/stream") async def starte_sortierung_stream(db: Session = Depends(get_db)): """Streaming-Endpoint für Feinsortierung mit Live-Updates""" from ..models.database import SessionLocal # Daten vorab laden (Session ist im Generator nicht verfügbar) ordner_liste = db.query(QuellOrdner).filter(QuellOrdner.aktiv == True).all() ordner_daten = [{ "id": o.id, "name": o.name, "ziel_ordner": o.ziel_ordner, "dateitypen": o.dateitypen, "ocr_aktivieren": getattr(o, 'ocr_aktivieren', True), "original_sichern": getattr(o, 'original_sichern', None), "signiert_behandlung": getattr(o, 'signiert_behandlung', 'normal') } for o in ordner_liste] # Fallback-Regeln laden fallback_regeln = db.query(SortierRegel).filter( SortierRegel.aktiv == True, SortierRegel.ist_fallback == True ).order_by(SortierRegel.prioritaet).all() fallback_dicts = [{ "id": r.id, "name": r.name, "prioritaet": r.prioritaet, "muster": r.muster, "extraktion": r.extraktion, "schema": r.schema, "unterordner": r.unterordner, "ziel_ordner": getattr(r, 'ziel_ordner', None), "nur_umbenennen": getattr(r, 'nur_umbenennen', False) } for r in fallback_regeln] # Ordner-Regeln vorladen ordner_regeln_map = {} for o in ordner_daten: zuweisungen = db.query(OrdnerRegel).filter(OrdnerRegel.ordner_id == o["id"]).all() zugewiesene_ids = [z.regel_id for z in zuweisungen] if zugewiesene_ids: regeln = db.query(SortierRegel).filter( SortierRegel.id.in_(zugewiesene_ids), SortierRegel.aktiv == True, SortierRegel.ist_fallback == False ).order_by(SortierRegel.prioritaet).all() ordner_regeln_map[o["id"]] = [{ "id": r.id, "name": r.name, "prioritaet": r.prioritaet, "muster": r.muster, "extraktion": r.extraktion, "schema": r.schema, "unterordner": r.unterordner, "ziel_ordner": getattr(r, 'ziel_ordner', None), "nur_umbenennen": getattr(r, 'nur_umbenennen', False) } for r in regeln] else: ordner_regeln_map[o["id"]] = [] # Dateien vorab zählen für Gesamtübersicht gesamt_dateien = 0 for o in ordner_daten: pfad = Path(o["ziel_ordner"]) if pfad.exists(): dateien = sammle_dateien_aus_pfad(str(pfad), [".pdf"], rekursiv=True) gesamt_dateien += len(dateien) async def event_generator(): def send_event(data): return f"data: {json.dumps(data)}\n\n" if not ordner_daten: yield send_event({"type": "fehler", "nachricht": "Keine Quell-Ordner konfiguriert"}) return pdf_processor = PDFProcessor() ergebnis = {"gesamt": 0, "sortiert": 0, "zugferd": 0, "fehler": 0} yield send_event({"type": "start", "ordner_count": len(ordner_daten), "gesamt": gesamt_dateien}) await asyncio.sleep(0) # Sofort senden session = SessionLocal() try: for quell_ordner in ordner_daten: pfad = Path(quell_ordner["ziel_ordner"]) if not pfad.exists(): continue dateien = sammle_dateien_aus_pfad(str(pfad), [".pdf"], rekursiv=True) regeln_dicts = ordner_regeln_map.get(quell_ordner["id"], []) yield send_event({ "type": "ordner", "ordner": quell_ordner["name"], "dateien": len(dateien), "regeln": len(regeln_dicts) }) await asyncio.sleep(0) # Sofort senden sorter = Sorter(regeln_dicts) if regeln_dicts else None fallback_sorter = Sorter(fallback_dicts) if fallback_dicts else None for datei in dateien: ergebnis["gesamt"] += 1 datei_info = {"original": datei.name, "ordner": quell_ordner["name"]} yield send_event({"type": "datei_start", "datei": datei.name}) await asyncio.sleep(0) # Sofort senden try: ist_pdf = datei.suffix.lower() == ".pdf" text = "" ocr_gemacht = False if ist_pdf: pdf_result = pdf_processor.verarbeite( str(datei), ocr_erlaubt=quell_ordner.get("ocr_aktivieren", True), original_backup_pfad=quell_ordner.get("original_sichern") ) if pdf_result.get("fehler"): raise Exception(pdf_result["fehler"]) text = pdf_result.get("text", "") ocr_gemacht = pdf_result.get("ocr_durchgefuehrt", False) doc_info = { "text": text, "original_name": datei.name, "absender": "", "dateityp": datei.suffix.lower() } # Passende Regel finden regel = None if sorter: regel = sorter.finde_passende_regel(doc_info) if not regel and fallback_sorter: regel = fallback_sorter.finde_passende_regel(doc_info) if regel: datei_info["fallback"] = True if not regel: datei_info["fehler"] = "Keine passende Regel" ergebnis["fehler"] += 1 yield send_event({"type": "datei_fehler", **datei_info}) await asyncio.sleep(0) continue # Felder extrahieren und umbenennen extrahiert = (sorter or fallback_sorter).extrahiere_felder(regel, doc_info) schema = regel.get("schema", "{datum} - Dokument.pdf") if schema.endswith(".pdf"): schema = schema[:-4] + datei.suffix neuer_name = (sorter or fallback_sorter).generiere_dateinamen( {"schema": schema, **regel}, extrahiert ) # Zielordner if regel.get("nur_umbenennen"): ziel = datei.parent elif regel.get("ziel_ordner"): ziel = Path(regel["ziel_ordner"]) if regel.get("unterordner"): ziel = ziel / regel["unterordner"] else: ziel = pfad if regel.get("unterordner"): ziel = ziel / regel["unterordner"] ziel.mkdir(parents=True, exist_ok=True) neuer_pfad = (sorter or fallback_sorter).verschiebe_datei(str(datei), str(ziel), neuer_name) ergebnis["sortiert"] += 1 datei_info["neuer_name"] = neuer_name datei_info["regel"] = regel.get("name", "Unbekannt") session.add(VerarbeiteteDatei( original_pfad=str(datei), original_name=datei.name, neuer_pfad=neuer_pfad, neuer_name=neuer_name, ocr_durchgefuehrt=ocr_gemacht, status="sortiert", extrahierte_daten=extrahiert )) yield send_event({"type": "datei_fertig", **datei_info}) await asyncio.sleep(0) # Sofort senden except Exception as e: ergebnis["fehler"] += 1 datei_info["fehler"] = str(e) yield send_event({"type": "datei_fehler", **datei_info}) await asyncio.sleep(0) session.commit() finally: session.close() yield send_event({"type": "fertig", **ergebnis}) return StreamingResponse( event_generator(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" } ) # ============ PDF Test / Regel-Vorschau ============ @router.post("/pdf/extrahieren") async def extrahiere_pdf_text(datei: UploadFile = File(...)): """Extrahiert Text aus einer hochgeladenen PDF für Regel-Tests""" if not datei.filename.lower().endswith('.pdf'): raise HTTPException(status_code=400, detail="Nur PDF-Dateien erlaubt") # Temporäre Datei erstellen with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp: inhalt = await datei.read() tmp.write(inhalt) tmp_pfad = tmp.name try: pdf_processor = PDFProcessor() ergebnis = pdf_processor.verarbeite(tmp_pfad) return { "dateiname": datei.filename, "text": ergebnis.get("text", ""), "ist_zugferd": ergebnis.get("ist_zugferd", False), "ist_signiert": ergebnis.get("ist_signiert", False), "hat_text": ergebnis.get("hat_text", False), "ocr_durchgefuehrt": ergebnis.get("ocr_durchgefuehrt", False), "seiten": ergebnis.get("seiten", 0) } finally: # Temporäre Datei löschen Path(tmp_pfad).unlink(missing_ok=True) @router.post("/pdf/testen") async def teste_pdf_mit_regel(datei: UploadFile = File(...), regel_json: str = "{}"): """Testet eine Regel gegen eine hochgeladene PDF""" if not datei.filename.lower().endswith('.pdf'): raise HTTPException(status_code=400, detail="Nur PDF-Dateien erlaubt") # Regel parsen try: regel = json.loads(regel_json) except: regel = {} # Temporäre Datei erstellen with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp: inhalt = await datei.read() tmp.write(inhalt) tmp_pfad = tmp.name try: # PDF verarbeiten pdf_processor = PDFProcessor() pdf_ergebnis = pdf_processor.verarbeite(tmp_pfad) text = pdf_ergebnis.get("text", "") # Regel testen regel["aktiv"] = True regel["prioritaet"] = 1 sorter = Sorter([regel]) doc_info = { "text": text, "original_name": datei.filename, "absender": "" } passend = sorter.finde_passende_regel(doc_info) ergebnis = { "dateiname": datei.filename, "text": text, "text_vorschau": text[:2000] if len(text) > 2000 else text, "ist_zugferd": pdf_ergebnis.get("ist_zugferd", False), "regel_passt": passend is not None, "extrahiert": {}, "vorgeschlagener_name": "" } if passend: extrahiert = sorter.extrahiere_felder(passend, doc_info) ergebnis["extrahiert"] = extrahiert ergebnis["vorgeschlagener_name"] = sorter.generiere_dateinamen(passend, extrahiert) # Muster-Matches hervorheben muster = regel.get("muster", {}) matches = [] # Keywords finden if "keywords" in muster: keywords = muster["keywords"] if isinstance(keywords, str): keywords = [k.strip() for k in keywords.split(",")] for kw in keywords: if kw.strip(): for m in re.finditer(re.escape(kw.strip()), text, re.IGNORECASE): matches.append({"start": m.start(), "end": m.end(), "text": m.group(), "typ": "keyword"}) # text_match finden if "text_match" in muster: patterns = muster["text_match"] if isinstance(patterns, str): patterns = [patterns] for p in patterns: for m in re.finditer(re.escape(p), text, re.IGNORECASE): matches.append({"start": m.start(), "end": m.end(), "text": m.group(), "typ": "text_match"}) # text_regex finden if "text_regex" in muster: try: for m in re.finditer(muster["text_regex"], text, re.IGNORECASE): matches.append({"start": m.start(), "end": m.end(), "text": m.group(), "typ": "regex"}) except: pass ergebnis["matches"] = matches return ergebnis finally: Path(tmp_pfad).unlink(missing_ok=True) # ============ Debug Log ============ import logging from collections import deque # In-Memory Log-Speicher (maximal 500 Einträge) log_speicher = deque(maxlen=500) class UILogHandler(logging.Handler): """Log Handler der Einträge für die UI speichert""" def emit(self, record): log_speicher.append({ "zeit": datetime.now().strftime("%H:%M:%S"), "level": record.levelname, "nachricht": self.format(record) }) # Handler registrieren ui_handler = UILogHandler() ui_handler.setLevel(logging.DEBUG) ui_handler.setFormatter(logging.Formatter('%(message)s')) # Zu allen relevanten Loggern hinzufügen for logger_name in ['backend.app', 'root', '']: logger = logging.getLogger(logger_name) logger.addHandler(ui_handler) @router.get("/logs") def hole_logs(level: str = None): """Gibt Log-Einträge zurück""" logs = list(log_speicher) if level: logs = [l for l in logs if l["level"] == level] return logs @router.delete("/logs") def loesche_logs(): """Leert den Log-Speicher""" log_speicher.clear() return {"message": "Logs gelöscht"} @router.get("/health") def health(): return {"status": "ok"} # ============ Auto-Regex Generator ============ @router.post("/pdf/auto-regex") async def auto_regex_generator(datei: UploadFile = File(...)): """ Analysiert eine PDF und generiert automatisch Regex-Muster für erkannte Felder. Erkennt: Datum, Betrag, Rechnungsnummer, Firma """ if not datei.filename.lower().endswith('.pdf'): raise HTTPException(status_code=400, detail="Nur PDF-Dateien erlaubt") # Temporäre Datei erstellen with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp: inhalt = await datei.read() tmp.write(inhalt) tmp_pfad = tmp.name try: # PDF verarbeiten pdf_processor = PDFProcessor() pdf_ergebnis = pdf_processor.verarbeite(tmp_pfad) text = pdf_ergebnis.get("text", "") erkannte_felder = [] # ============ DATUM erkennen ============ datum_patterns = [ {"regex": r"Rechnungsdatum[:\s]*(\d{2})[./](\d{2})[./](\d{4})", "kontext": "Rechnungsdatum", "prio": 1}, {"regex": r"Belegdatum[:\s]*(\d{2})[./](\d{2})[./](\d{4})", "kontext": "Belegdatum", "prio": 1}, {"regex": r"Datum[:\s]*(\d{2})[./](\d{2})[./](\d{4})", "kontext": "Datum", "prio": 2}, {"regex": r"vom[:\s]*(\d{2})[./](\d{2})[./](\d{4})", "kontext": "vom", "prio": 2}, {"regex": r"(\d{2})\.(\d{2})\.(\d{4})", "kontext": "Format TT.MM.JJJJ", "prio": 3}, {"regex": r"(\d{4})-(\d{2})-(\d{2})", "kontext": "ISO Format", "prio": 3}, ] for pattern in datum_patterns: match = re.search(pattern["regex"], text, re.IGNORECASE) if match: erkannte_felder.append({ "feld": "datum", "wert": match.group(0), "position": match.start(), "kontext": pattern["kontext"], "regex_vorschlag": pattern["regex"], "prioritaet": pattern["prio"] }) break # ============ BETRAG erkennen ============ betrag_patterns = [ {"regex": r"Gesamtbetrag[:\s]*([\d.,]+)\s*(?:EUR|€)?", "kontext": "Gesamtbetrag", "prio": 1}, {"regex": r"Rechnungsbetrag[:\s]*([\d.,]+)\s*(?:EUR|€)?", "kontext": "Rechnungsbetrag", "prio": 1}, {"regex": r"Endbetrag[:\s]*([\d.,]+)\s*(?:EUR|€)?", "kontext": "Endbetrag", "prio": 1}, {"regex": r"Summe[:\s]*([\d.,]+)\s*(?:EUR|€)?", "kontext": "Summe", "prio": 2}, {"regex": r"Total[:\s]*([\d.,]+)\s*(?:EUR|€)?", "kontext": "Total", "prio": 2}, {"regex": r"Brutto[:\s]*([\d.,]+)\s*(?:EUR|€)?", "kontext": "Brutto", "prio": 2}, {"regex": r"zu zahlen[:\s]*([\d.,]+)\s*(?:EUR|€)?", "kontext": "zu zahlen", "prio": 1}, {"regex": r"([\d.,]+)\s*(?:EUR|€)", "kontext": "Betrag mit EUR/€", "prio": 3}, ] for pattern in betrag_patterns: match = re.search(pattern["regex"], text, re.IGNORECASE) if match: erkannte_felder.append({ "feld": "betrag", "wert": match.group(0), "extrahiert": match.group(1) if match.lastindex else match.group(0), "position": match.start(), "kontext": pattern["kontext"], "regex_vorschlag": pattern["regex"], "prioritaet": pattern["prio"] }) break # ============ RECHNUNGSNUMMER erkennen ============ nummer_patterns = [ {"regex": r"Rechnungsnummer[:\s#]*([A-Z0-9][\w\-/]+)", "kontext": "Rechnungsnummer", "prio": 1}, {"regex": r"Rechnung\s*Nr\.?[:\s#]*([A-Z0-9][\w\-/]+)", "kontext": "Rechnung Nr.", "prio": 1}, {"regex": r"Rechnungs-Nr\.?[:\s#]*([A-Z0-9][\w\-/]+)", "kontext": "Rechnungs-Nr.", "prio": 1}, {"regex": r"Invoice\s*(?:No\.?|Number)?[:\s#]*([A-Z0-9][\w\-/]+)", "kontext": "Invoice", "prio": 1}, {"regex": r"Beleg-?Nr\.?[:\s#]*([A-Z0-9][\w\-/]+)", "kontext": "Beleg-Nr.", "prio": 2}, {"regex": r"Dokumentnummer[:\s#]*([A-Z0-9][\w\-/]+)", "kontext": "Dokumentnummer", "prio": 2}, {"regex": r"RE-?(\d{4,})", "kontext": "RE-Nummer", "prio": 3}, {"regex": r"INV-?(\d{4,})", "kontext": "INV-Nummer", "prio": 3}, ] for pattern in nummer_patterns: match = re.search(pattern["regex"], text, re.IGNORECASE) if match: erkannte_felder.append({ "feld": "nummer", "wert": match.group(0), "extrahiert": match.group(1) if match.lastindex else match.group(0), "position": match.start(), "kontext": pattern["kontext"], "regex_vorschlag": pattern["regex"], "prioritaet": pattern["prio"] }) break # ============ FIRMA erkennen ============ bekannte_firmen = [ "Sonepar", "Amazon", "Ebay", "MediaMarkt", "Saturn", "Conrad", "Reichelt", "Hornbach", "Bauhaus", "OBI", "Hagebau", "Toom", "Hellweg", "Telekom", "Vodafone", "O2", "1&1", "Allianz", "HUK", "Provinzial", "DEVK", "Gothaer", "IKEA", "Poco", "XXXLutz", "Roller", "Alternate", "Mindfactory", "Caseking", "Notebooksbilliger", "DHL", "DPD", "Hermes", "UPS", "GLS", ] text_lower = text.lower() for firma in bekannte_firmen: if firma.lower() in text_lower: # Position im Original-Text finden pos = text_lower.find(firma.lower()) erkannte_felder.append({ "feld": "firma", "wert": firma, "position": pos, "kontext": "Bekannte Firma", "regex_vorschlag": f"(?i){re.escape(firma)}", "prioritaet": 1 }) break # Wenn keine bekannte Firma: nach GmbH, AG etc. suchen if not any(f["feld"] == "firma" for f in erkannte_felder): firma_match = re.search(r"([A-ZÄÖÜ][A-Za-zäöüÄÖÜß\s&\-\.]+(?:GmbH|AG|KG|e\.K\.|Inc|Ltd|SE|UG))", text) if firma_match: erkannte_felder.append({ "feld": "firma", "wert": firma_match.group(1).strip(), "position": firma_match.start(), "kontext": "Firmenname mit Rechtsform", "regex_vorschlag": r"([A-ZÄÖÜ][A-Za-zäöüÄÖÜß\s&\-\.]+(?:GmbH|AG|KG|e\.K\.|Inc|Ltd|SE|UG))", "prioritaet": 2 }) # ============ Keywords für Dokumenttyp extrahieren ============ dokumenttyp_keywords = { "rechnung": ["rechnung", "invoice", "faktura"], "angebot": ["angebot", "quotation", "offerte"], "gutschrift": ["gutschrift", "credit note"], "lieferschein": ["lieferschein", "delivery note"], } gefundene_keywords = [] for typ, keywords in dokumenttyp_keywords.items(): for kw in keywords: if kw in text_lower: gefundene_keywords.append(kw) # Regel-Vorschlag erstellen regel_vorschlag = { "muster": {}, "extraktion": {}, "schema": "{datum} - Rechnung - {firma} - {nummer} - {betrag} EUR.pdf" } if gefundene_keywords: regel_vorschlag["muster"]["keywords"] = ", ".join(gefundene_keywords[:3]) for feld in erkannte_felder: if feld["feld"] in ["datum", "betrag", "nummer"]: regel_vorschlag["extraktion"][feld["feld"]] = { "regex": feld["regex_vorschlag"] } elif feld["feld"] == "firma": regel_vorschlag["extraktion"]["firma"] = { "wert": feld["wert"] } return { "dateiname": datei.filename, "text_laenge": len(text), "erkannte_felder": erkannte_felder, "gefundene_keywords": gefundene_keywords, "regel_vorschlag": regel_vorschlag, "text_vorschau": text[:3000] if len(text) > 3000 else text } finally: Path(tmp_pfad).unlink(missing_ok=True) # ============ Einfache Regeln (UI-freundlich) ============ @router.get("/dokumenttypen") def liste_dokumenttypen(): """Gibt alle verfügbaren Dokumenttypen für das UI zurück""" from ..modules.sorter import DOKUMENTTYPEN return [ {"id": key, "name": config["name"], "schema": config["schema"], "unterordner": config["unterordner"]} for key, config in DOKUMENTTYPEN.items() ] class EinfacheRegelCreate(BaseModel): name: str dokumenttyp: str # z.B. "rechnung", "vertrag" keywords: str # Komma-getrennt firma: Optional[str] = None # Fester Firmenwert unterordner: Optional[str] = None prioritaet: int = 50 @router.post("/regeln/einfach") def erstelle_einfache_regel_api(data: EinfacheRegelCreate, db: Session = Depends(get_db)): """Erstellt eine Regel basierend auf Dokumenttyp - für einfaches UI""" from ..modules.sorter import DOKUMENTTYPEN typ_config = DOKUMENTTYPEN.get(data.dokumenttyp, DOKUMENTTYPEN["sonstiges"]) # Muster als Dict (keywords werden vom Sorter geparst) muster = {"keywords": data.keywords} # Extraktion (nur Firma wenn angegeben) extraktion = {} if data.firma: extraktion["firma"] = {"wert": data.firma} regel = SortierRegel( name=data.name, prioritaet=data.prioritaet, aktiv=True, muster=muster, extraktion=extraktion, schema=typ_config["schema"], unterordner=data.unterordner or typ_config["unterordner"] ) db.add(regel) db.commit() db.refresh(regel) return { "id": regel.id, "name": regel.name, "dokumenttyp": data.dokumenttyp, "keywords": data.keywords, "schema": regel.schema } class ExtraktionTestRequest(BaseModel): text: str dateiname: Optional[str] = "test.pdf" @router.post("/extraktion/test") def teste_extraktion(data: ExtraktionTestRequest): """Testet die automatische Extraktion auf einem Text""" from ..modules.extraktoren import extrahiere_alle_felder, baue_dateiname dokument_info = { "original_name": data.dateiname, "absender": "" } # Felder extrahieren felder = extrahiere_alle_felder(data.text, dokument_info) # Beispiel-Dateinamen für verschiedene Typen generieren beispiele = {} from ..modules.sorter import DOKUMENTTYPEN for typ_id, typ_config in DOKUMENTTYPEN.items(): beispiele[typ_id] = baue_dateiname(typ_config["schema"], felder, ".pdf") return { "extrahiert": felder, "beispiel_dateinamen": beispiele } @router.post("/regeln/{id}/vorschau") def regel_vorschau(id: int, data: ExtraktionTestRequest, db: Session = Depends(get_db)): """Zeigt Vorschau wie eine Regel auf einen Text angewendet würde""" regel = db.query(SortierRegel).filter(SortierRegel.id == id).first() if not regel: raise HTTPException(status_code=404, detail="Regel nicht gefunden") from ..modules.sorter import Sorter sorter = Sorter([{ "id": regel.id, "name": regel.name, "prioritaet": regel.prioritaet, "aktiv": True, "muster": regel.muster, "extraktion": regel.extraktion, "schema": regel.schema, "unterordner": regel.unterordner }]) dokument_info = { "text": data.text, "original_name": data.dateiname or "test.pdf", "absender": "" } # Prüfen ob Regel matched passende_regel = sorter.finde_passende_regel(dokument_info) if not passende_regel: return { "matched": False, "grund": "Keywords nicht gefunden" } # Felder extrahieren felder = sorter.extrahiere_felder(passende_regel, dokument_info) # Dateiname generieren dateiname = sorter.generiere_dateinamen(passende_regel, felder) return { "matched": True, "extrahiert": felder, "dateiname": dateiname, "unterordner": passende_regel.get("unterordner") } # ============ BEREICH 3: Zeitpläne / Scheduler ============ class ZeitplanCreate(BaseModel): name: str typ: str # "mail_abruf", "grobsortierung", "sortierregeln", "db_backup" postfach_id: Optional[int] = None quell_ordner_id: Optional[int] = None regel_id: Optional[int] = None # Für typ="sortierregeln" datenbank_id: Optional[int] = None # Für typ="db_backup" intervall: str # "stündlich", "täglich", "wöchentlich", "monatlich" stunde: int = 6 minute: int = 0 wochentag: Optional[int] = None # 0=Montag monatstag: Optional[int] = None class ZeitplanResponse(BaseModel): id: int name: str aktiv: bool typ: str postfach_id: Optional[int] quell_ordner_id: Optional[int] regel_id: Optional[int] datenbank_id: Optional[int] intervall: str stunde: int minute: int wochentag: Optional[int] monatstag: Optional[int] letzte_ausfuehrung: Optional[datetime] naechste_ausfuehrung: Optional[datetime] letzter_status: Optional[str] letzte_meldung: Optional[str] class Config: from_attributes = True @router.get("/zeitplaene") def liste_zeitplaene(db: Session = Depends(get_db)): """Listet alle Zeitpläne mit Status""" from ..services.scheduler_service import get_scheduler_status return get_scheduler_status() @router.get("/zeitplaene/{id}", response_model=ZeitplanResponse) def hole_zeitplan(id: int, db: Session = Depends(get_db)): """Gibt einen einzelnen Zeitplan zurück""" zeitplan = db.query(Zeitplan).filter(Zeitplan.id == id).first() if not zeitplan: raise HTTPException(404, "Zeitplan nicht gefunden") return zeitplan @router.post("/zeitplaene", response_model=ZeitplanResponse) def erstelle_zeitplan(data: ZeitplanCreate, db: Session = Depends(get_db)): """Erstellt einen neuen Zeitplan""" zeitplan = Zeitplan(**data.dict()) db.add(zeitplan) db.commit() db.refresh(zeitplan) # Scheduler aktualisieren from ..services.scheduler_service import sync_zeitplaene, trigger_zeitplan_manuell sync_zeitplaene() # Zeitplan direkt beim Erstellen einmal ausführen (in separatem Thread) import threading def run_zeitplan(): try: trigger_zeitplan_manuell(zeitplan.id) except Exception as e: import logging logging.getLogger(__name__).warning(f"Fehler bei sofortiger Ausführung: {e}") thread = threading.Thread(target=run_zeitplan, daemon=True) thread.start() return zeitplan @router.put("/zeitplaene/{id}", response_model=ZeitplanResponse) def aktualisiere_zeitplan(id: int, data: ZeitplanCreate, db: Session = Depends(get_db)): """Aktualisiert einen Zeitplan""" zeitplan = db.query(Zeitplan).filter(Zeitplan.id == id).first() if not zeitplan: raise HTTPException(status_code=404, detail="Nicht gefunden") for key, value in data.dict().items(): setattr(zeitplan, key, value) db.commit() db.refresh(zeitplan) # Scheduler aktualisieren from ..services.scheduler_service import sync_zeitplaene, trigger_zeitplan_manuell sync_zeitplaene() # Zeitplan direkt beim Speichern einmal ausführen (in separatem Thread) import threading def run_zeitplan(): try: trigger_zeitplan_manuell(zeitplan.id) except Exception as e: import logging logging.getLogger(__name__).warning(f"Fehler bei sofortiger Ausführung: {e}") thread = threading.Thread(target=run_zeitplan, daemon=True) thread.start() return zeitplan @router.delete("/zeitplaene/{id}") def loesche_zeitplan(id: int, db: Session = Depends(get_db)): """Löscht einen Zeitplan""" zeitplan = db.query(Zeitplan).filter(Zeitplan.id == id).first() if not zeitplan: raise HTTPException(status_code=404, detail="Nicht gefunden") db.delete(zeitplan) db.commit() # Scheduler aktualisieren from ..services.scheduler_service import sync_zeitplaene sync_zeitplaene() return {"message": "Gelöscht"} @router.post("/zeitplaene/{id}/aktivieren") def aktiviere_zeitplan(id: int, db: Session = Depends(get_db)): """Aktiviert/Deaktiviert einen Zeitplan""" zeitplan = db.query(Zeitplan).filter(Zeitplan.id == id).first() if not zeitplan: raise HTTPException(status_code=404, detail="Nicht gefunden") zeitplan.aktiv = not zeitplan.aktiv db.commit() # Scheduler aktualisieren from ..services.scheduler_service import sync_zeitplaene sync_zeitplaene() return {"aktiv": zeitplan.aktiv} @router.post("/zeitplaene/{id}/ausfuehren") def fuehre_zeitplan_aus(id: int, db: Session = Depends(get_db)): """Führt einen Zeitplan sofort manuell aus""" from ..services.scheduler_service import trigger_zeitplan_manuell return trigger_zeitplan_manuell(id) @router.get("/status/uebersicht") def status_uebersicht(db: Session = Depends(get_db)): """Gibt eine Übersicht über alle Services und deren Status""" from ..services.scheduler_service import get_scheduler_status # Postfächer Status postfaecher = db.query(Postfach).all() postfach_status = [{ "id": p.id, "name": p.name, "aktiv": p.aktiv, "letzter_abruf": p.letzter_abruf.isoformat() if p.letzter_abruf else None, "letzte_anzahl": p.letzte_anzahl } for p in postfaecher] # Quellordner Status ordner = db.query(QuellOrdner).all() ordner_status = [{ "id": o.id, "name": o.name, "aktiv": o.aktiv, "pfad": o.pfad } for o in ordner] # Scheduler Status scheduler_status = get_scheduler_status() return { "postfaecher": postfach_status, "quell_ordner": ordner_status, "scheduler": scheduler_status } # ============ DB-Server API ============ class DbServerCreate(BaseModel): name: str typ: str # mariadb, mysql, postgresql host: str port: int = 3306 user: str password: str class DbServerResponse(BaseModel): id: int name: str typ: str host: str port: int user: str aktiv: bool class Config: from_attributes = True @router.get("/dbserver") def liste_dbserver(db: Session = Depends(get_db)): """Liste aller DB-Server""" return db.query(DbServer).all() @router.get("/dbserver/{id}") def hole_dbserver(id: int, db: Session = Depends(get_db)): """Einzelnen DB-Server abrufen""" server = db.query(DbServer).filter(DbServer.id == id).first() if not server: raise HTTPException(status_code=404, detail="Nicht gefunden") return { "id": server.id, "name": server.name, "typ": server.typ, "host": server.host, "port": server.port, "user": server.user, "aktiv": server.aktiv } @router.post("/dbserver") def erstelle_dbserver(data: DbServerCreate, db: Session = Depends(get_db)): """Neuen DB-Server erstellen""" server = DbServer( name=data.name, typ=data.typ, host=data.host, port=data.port, user=data.user, passwort=data.password ) db.add(server) db.commit() db.refresh(server) return {"id": server.id, "message": "Erstellt"} @router.put("/dbserver/{id}") def aktualisiere_dbserver(id: int, data: DbServerCreate, db: Session = Depends(get_db)): """DB-Server aktualisieren""" server = db.query(DbServer).filter(DbServer.id == id).first() if not server: raise HTTPException(status_code=404, detail="Nicht gefunden") server.name = data.name server.typ = data.typ server.host = data.host server.port = data.port server.user = data.user if data.password: server.passwort = data.password db.commit() return {"message": "Aktualisiert"} @router.delete("/dbserver/{id}") def loesche_dbserver(id: int, db: Session = Depends(get_db)): """DB-Server löschen""" server = db.query(DbServer).filter(DbServer.id == id).first() if not server: raise HTTPException(status_code=404, detail="Nicht gefunden") db.delete(server) db.commit() return {"message": "Gelöscht"} @router.post("/dbserver/{id}/aktivieren") def aktiviere_dbserver(id: int, db: Session = Depends(get_db)): """DB-Server aktivieren/deaktivieren""" server = db.query(DbServer).filter(DbServer.id == id).first() if not server: raise HTTPException(status_code=404, detail="Nicht gefunden") server.aktiv = not server.aktiv db.commit() return {"aktiv": server.aktiv} class DbServerTest(BaseModel): typ: str host: str port: int = 3306 user: str password: str @router.post("/dbserver/test") def teste_dbserver(data: DbServerTest): """Testet die Verbindung zu einem DB-Server""" try: if data.typ in ["mariadb", "mysql"]: import pymysql conn = pymysql.connect( host=data.host, port=data.port, user=data.user, password=data.password, connect_timeout=5 ) conn.close() elif data.typ == "postgresql": import psycopg2 conn = psycopg2.connect( host=data.host, port=data.port, user=data.user, password=data.password, connect_timeout=5 ) conn.close() else: raise HTTPException(status_code=400, detail="Unbekannter DB-Typ") return {"message": "Verbindung erfolgreich!"} except Exception as e: raise HTTPException(status_code=400, detail=str(e)) # ============ Datenbanken API ============ class DatenbankCreate(BaseModel): name: str server_id: int database: str backup_pfad: str aufbewahrung: int = 7 format: str = "sql" # sql, sql.gz, zip class DatenbankResponse(BaseModel): id: int name: str server_id: int database: str backup_pfad: str aufbewahrung: int format: str aktiv: bool letztes_backup: Optional[datetime] server_name: Optional[str] = None class Config: from_attributes = True @router.get("/datenbanken") def liste_datenbanken(db: Session = Depends(get_db)): """Liste aller Datenbanken mit Server-Namen""" dbs = db.query(Datenbank).all() result = [] for d in dbs: server = db.query(DbServer).filter(DbServer.id == d.server_id).first() result.append({ "id": d.id, "name": d.name, "server_id": d.server_id, "server_name": server.name if server else None, "database": d.database, "backup_pfad": d.backup_pfad, "aufbewahrung": d.aufbewahrung, "format": d.format, "aktiv": d.aktiv, "letztes_backup": d.letztes_backup }) return result @router.get("/datenbanken/{id}") def hole_datenbank(id: int, db: Session = Depends(get_db)): """Einzelne Datenbank abrufen""" datenbank = db.query(Datenbank).filter(Datenbank.id == id).first() if not datenbank: raise HTTPException(status_code=404, detail="Nicht gefunden") return datenbank @router.post("/datenbanken") def erstelle_datenbank(data: DatenbankCreate, db: Session = Depends(get_db)): """Neue Datenbank-Konfiguration erstellen""" datenbank = Datenbank(**data.dict()) db.add(datenbank) db.commit() db.refresh(datenbank) return {"id": datenbank.id, "message": "Erstellt"} @router.put("/datenbanken/{id}") def aktualisiere_datenbank(id: int, data: DatenbankCreate, db: Session = Depends(get_db)): """Datenbank-Konfiguration aktualisieren""" datenbank = db.query(Datenbank).filter(Datenbank.id == id).first() if not datenbank: raise HTTPException(status_code=404, detail="Nicht gefunden") for key, value in data.dict().items(): setattr(datenbank, key, value) db.commit() return {"message": "Aktualisiert"} @router.delete("/datenbanken/{id}") def loesche_datenbank(id: int, db: Session = Depends(get_db)): """Datenbank-Konfiguration löschen""" datenbank = db.query(Datenbank).filter(Datenbank.id == id).first() if not datenbank: raise HTTPException(status_code=404, detail="Nicht gefunden") db.delete(datenbank) db.commit() return {"message": "Gelöscht"} @router.post("/datenbanken/{id}/aktivieren") def aktiviere_datenbank(id: int, db: Session = Depends(get_db)): """Datenbank aktivieren/deaktivieren""" datenbank = db.query(Datenbank).filter(Datenbank.id == id).first() if not datenbank: raise HTTPException(status_code=404, detail="Nicht gefunden") datenbank.aktiv = not datenbank.aktiv db.commit() return {"aktiv": datenbank.aktiv} @router.post("/datenbanken/{id}/backup") def erstelle_backup(id: int, db: Session = Depends(get_db)): """Erstellt ein Backup der angegebenen Datenbank""" from ..services.backup_service import erstelle_db_backup return erstelle_db_backup(id, db) @router.get("/backups") def liste_backups(db: Session = Depends(get_db)): """Liste der letzten Backups""" logs = db.query(BackupLog).order_by(BackupLog.erstellt_am.desc()).limit(50).all() result = [] for log in logs: datenbank = db.query(Datenbank).filter(Datenbank.id == log.datenbank_id).first() result.append({ "id": log.id, "datenbank_name": datenbank.name if datenbank else "Unbekannt", "dateiname": log.dateiname, "groesse_mb": round(log.groesse_bytes / 1024 / 1024, 2) if log.groesse_bytes else 0, "status": log.status, "erstellt": log.erstellt_am.strftime("%d.%m.%Y %H:%M") if log.erstellt_am else None }) return result