""" API Routes - Getrennte Bereiche: Mail-Abruf und Datei-Sortierung """ from fastapi import APIRouter, Depends, HTTPException 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 from ..models.database import get_db, Postfach, QuellOrdner, SortierRegel, VerarbeiteteDatei, VerarbeiteteMail from ..modules.mail_fetcher import MailFetcher from ..modules.pdf_processor import PDFProcessor from ..modules.sorter import Sorter 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 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 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"] class OrdnerResponse(BaseModel): id: int name: str pfad: str ziel_ordner: str rekursiv: bool dateitypen: List[str] aktiv: bool 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 class RegelResponse(BaseModel): id: int name: str prioritaet: int aktiv: bool muster: dict extraktion: dict schema: str unterordner: Optional[str] 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: PostfachCreate, 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, "ziel_ordner": postfach.ziel_ordner } # 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"] }) 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 }) 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 }) 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} # ============ 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.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.get("/ordner/{id}/scannen") def scanne_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") 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]]} # ============ Regeln ============ @router.get("/regeln", response_model=List[RegelResponse]) def liste_regeln(db: Session = Depends(get_db)): return db.query(SortierRegel).order_by(SortierRegel.prioritaet).all() @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/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} # ============ 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"])] 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() regeln = db.query(SortierRegel).filter(SortierRegel.aktiv == True).order_by(SortierRegel.prioritaet).all() if not ordner_liste: return {"fehler": "Keine Quell-Ordner konfiguriert", "verarbeitet": []} if not regeln: 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, "fehler": 0, "verarbeitet": [] } for quell_ordner in ordner_liste: pfad = Path(quell_ordner.pfad) if not pfad.exists(): continue ziel_basis = Path(quell_ordner.ziel_ordner) dateien = sammle_dateien(quell_ordner) for datei in dateien: ergebnis["gesamt"] += 1 # Relativer Pfad für Anzeige 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 separat behandeln if ist_zugferd: zugferd_ziel = ziel_basis / "zugferd" zugferd_ziel.mkdir(parents=True, exist_ok=True) neuer_pfad = zugferd_ziel / datei.name counter = 1 while neuer_pfad.exists(): neuer_pfad = zugferd_ziel / 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 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 # Regel finden (für PDFs mit Text, für andere nur Dateiname) 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["fehler"] = "Keine passende Regel" ergebnis["fehler"] += 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") # Endung aus Schema entfernen und Original-Endung anhängen 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) # Verschieben neuer_pfad = sorter.verschiebe_datei(str(datei), str(ziel), neuer_name) ergebnis["sortiert"] += 1 datei_info["neuer_name"] = neuer_name 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) db.commit() return ergebnis @router.get("/health") def health(): return {"status": "ok"} # ============ 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") }