first commit
This commit is contained in:
+327
@@ -0,0 +1,327 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request, Form, UploadFile
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from app.database import init_db, get_settings, save_settings, get_log_entries
|
||||
from app.mail_processor import process_mailbox, send_test_email, test_imap_connection, create_imap_folder
|
||||
from app.scheduler import start_scheduler, configure_job, get_scheduler_status
|
||||
from app.scanner import process_scanned_pdf, generate_separator_pdf, UPLOAD_DIR
|
||||
from app.smb_processor import process_smb_share, test_smb_connection, create_smb_folder, list_smb_folders
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
start_scheduler()
|
||||
settings = await get_settings()
|
||||
interval = int(settings.get("interval_minutes", 5))
|
||||
enabled = settings.get("scheduler_enabled", "false") == "true"
|
||||
configure_job(interval, enabled)
|
||||
logger.info("LexOffice Belegimport gestartet")
|
||||
yield
|
||||
logger.info("LexOffice Belegimport beendet")
|
||||
|
||||
|
||||
app = FastAPI(title="LexOffice Belegimport", lifespan=lifespan)
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request):
|
||||
settings = await get_settings()
|
||||
logs = await get_log_entries(limit=20)
|
||||
status = get_scheduler_status()
|
||||
return templates.TemplateResponse("settings.html", {
|
||||
"request": request,
|
||||
"settings": settings,
|
||||
"logs": logs,
|
||||
"status": status,
|
||||
"message": None,
|
||||
"message_type": None,
|
||||
})
|
||||
|
||||
|
||||
async def _save_form_settings(request: Request) -> dict:
|
||||
"""Extract form data and save settings. Returns the saved data dict."""
|
||||
form = await request.form()
|
||||
current = await get_settings()
|
||||
|
||||
data = {
|
||||
"imap_server": form.get("imap_server", ""),
|
||||
"imap_port": form.get("imap_port", "993"),
|
||||
"imap_ssl": form.get("imap_ssl", "true"),
|
||||
"imap_username": form.get("imap_username", ""),
|
||||
"imap_password": form.get("imap_password") or current.get("imap_password", ""),
|
||||
"smtp_server": form.get("smtp_server", ""),
|
||||
"smtp_port": form.get("smtp_port", "587"),
|
||||
"smtp_ssl": form.get("smtp_ssl", "starttls"),
|
||||
"smtp_username": form.get("smtp_username", ""),
|
||||
"smtp_password": form.get("smtp_password") or current.get("smtp_password", ""),
|
||||
"lexoffice_email": form.get("lexoffice_email", ""),
|
||||
"source_folder": form.get("source_folder", "Rechnungen"),
|
||||
"processed_folder": form.get("processed_folder", "Rechnungen/Verarbeitet"),
|
||||
"interval_minutes": form.get("interval_minutes", "5"),
|
||||
"scheduler_enabled": form.get("scheduler_enabled", "false"),
|
||||
"fetch_since_date": form.get("fetch_since_date", ""),
|
||||
# SMB
|
||||
"smb_enabled": form.get("smb_enabled", "false"),
|
||||
"smb_server": form.get("smb_server", ""),
|
||||
"smb_port": form.get("smb_port", "445"),
|
||||
"smb_username": form.get("smb_username", ""),
|
||||
"smb_password": form.get("smb_password") or current.get("smb_password", ""),
|
||||
"smb_domain": form.get("smb_domain", ""),
|
||||
"smb_share": form.get("smb_share", ""),
|
||||
"smb_source_path": form.get("smb_source_path", ""),
|
||||
"smb_processed_path": form.get("smb_processed_path", "Verarbeitet"),
|
||||
"smb_mode": form.get("smb_mode", "forward"),
|
||||
}
|
||||
|
||||
await save_settings(data)
|
||||
|
||||
interval = int(data["interval_minutes"])
|
||||
enabled = data["scheduler_enabled"] == "true"
|
||||
configure_job(interval, enabled)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@app.post("/settings", response_class=HTMLResponse)
|
||||
async def save(request: Request):
|
||||
await _save_form_settings(request)
|
||||
|
||||
settings = await get_settings()
|
||||
logs = await get_log_entries(limit=20)
|
||||
status = get_scheduler_status()
|
||||
return templates.TemplateResponse("settings.html", {
|
||||
"request": request,
|
||||
"settings": settings,
|
||||
"logs": logs,
|
||||
"status": status,
|
||||
"message": "Einstellungen gespeichert",
|
||||
"message_type": "success",
|
||||
})
|
||||
|
||||
|
||||
@app.post("/api/save-settings")
|
||||
async def api_save_settings(request: Request):
|
||||
await _save_form_settings(request)
|
||||
return JSONResponse({"success": True})
|
||||
|
||||
|
||||
@app.post("/api/test-imap")
|
||||
async def api_test_imap(request: Request):
|
||||
# Save settings first, then test
|
||||
await _save_form_settings(request)
|
||||
result = await test_imap_connection()
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.post("/api/test-email")
|
||||
async def api_test_email(request: Request):
|
||||
# Save settings first, then test
|
||||
await _save_form_settings(request)
|
||||
result = await send_test_email()
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.post("/api/process")
|
||||
async def api_process(request: Request):
|
||||
# Save settings first, then process
|
||||
await _save_form_settings(request)
|
||||
result = await process_mailbox()
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.post("/api/create-folder")
|
||||
async def api_create_folder(request: Request):
|
||||
body = await request.json()
|
||||
folder_name = body.get("folder_name", "")
|
||||
result = await create_imap_folder(folder_name)
|
||||
if result["success"]:
|
||||
# Return updated folder list
|
||||
folders_result = await test_imap_connection()
|
||||
result["folders"] = folders_result.get("folders", [])
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.post("/api/test-smb")
|
||||
async def api_test_smb(request: Request):
|
||||
await _save_form_settings(request)
|
||||
result = await test_smb_connection()
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.post("/api/process-smb")
|
||||
async def api_process_smb(request: Request):
|
||||
await _save_form_settings(request)
|
||||
result = await process_smb_share()
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.post("/api/create-smb-folder")
|
||||
async def api_create_smb_folder(request: Request):
|
||||
body = await request.json()
|
||||
folder_name = body.get("folder_name", "")
|
||||
result = await create_smb_folder(folder_name)
|
||||
if result["success"]:
|
||||
folders_result = await list_smb_folders()
|
||||
result["folders"] = folders_result.get("folders", [])
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/log", response_class=HTMLResponse)
|
||||
async def log_page(request: Request):
|
||||
logs = await get_log_entries(limit=500)
|
||||
return templates.TemplateResponse("log.html", {
|
||||
"request": request,
|
||||
"logs": logs,
|
||||
})
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
async def api_status():
|
||||
return get_scheduler_status()
|
||||
|
||||
|
||||
# --- Scan Upload ---
|
||||
|
||||
# In-memory progress store for SSE
|
||||
_scan_progress: dict[str, list[dict]] = {}
|
||||
|
||||
|
||||
@app.get("/scan", response_class=HTMLResponse)
|
||||
async def scan_page(request: Request):
|
||||
return templates.TemplateResponse("scan.html", {"request": request})
|
||||
|
||||
|
||||
@app.post("/api/scan-upload-chunk")
|
||||
async def scan_upload_chunk(
|
||||
request: Request,
|
||||
file: UploadFile = Form(...),
|
||||
chunk_index: int = Form(...),
|
||||
total_chunks: int = Form(...),
|
||||
upload_id: str = Form(...),
|
||||
filename: str = Form(...),
|
||||
):
|
||||
# Validate upload_id is UUID-like to prevent path traversal
|
||||
try:
|
||||
uuid.UUID(upload_id)
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "Ungültige Upload-ID"}, status_code=400)
|
||||
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
part_path = UPLOAD_DIR / f"{upload_id}.part"
|
||||
|
||||
# Append chunk to file
|
||||
mode = "ab" if chunk_index > 0 else "wb"
|
||||
content = await file.read()
|
||||
with open(part_path, mode) as f:
|
||||
f.write(content)
|
||||
|
||||
# If last chunk, rename to .pdf
|
||||
if chunk_index >= total_chunks - 1:
|
||||
pdf_path = UPLOAD_DIR / f"{upload_id}.pdf"
|
||||
part_path.rename(pdf_path)
|
||||
return JSONResponse({"status": "complete", "upload_id": upload_id})
|
||||
|
||||
return JSONResponse({"status": "ok", "chunk": chunk_index})
|
||||
|
||||
|
||||
@app.post("/api/scan-process")
|
||||
async def scan_process(request: Request):
|
||||
body = await request.json()
|
||||
upload_id = body.get("upload_id", "")
|
||||
|
||||
try:
|
||||
uuid.UUID(upload_id)
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "Ungültige Upload-ID"}, status_code=400)
|
||||
|
||||
pdf_path = UPLOAD_DIR / f"{upload_id}.pdf"
|
||||
if not pdf_path.exists():
|
||||
return JSONResponse({"error": "Upload nicht gefunden"}, status_code=404)
|
||||
|
||||
# Initialize progress
|
||||
_scan_progress[upload_id] = []
|
||||
|
||||
def progress_callback(stage, current, total, message=None):
|
||||
entry = {"stage": stage, "current": current, "total": total}
|
||||
if message:
|
||||
entry["message"] = message
|
||||
_scan_progress.setdefault(upload_id, []).append(entry)
|
||||
|
||||
# Process in background task
|
||||
async def _process():
|
||||
try:
|
||||
result = await process_scanned_pdf(str(pdf_path), progress_callback)
|
||||
_scan_progress.setdefault(upload_id, []).append({
|
||||
"stage": "done", "result": result
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Scan-Verarbeitung fehlgeschlagen: {e}")
|
||||
_scan_progress.setdefault(upload_id, []).append({
|
||||
"stage": "error", "message": str(e)
|
||||
})
|
||||
finally:
|
||||
# Cleanup uploaded file
|
||||
try:
|
||||
pdf_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
asyncio.create_task(_process())
|
||||
return JSONResponse({"status": "processing", "upload_id": upload_id})
|
||||
|
||||
|
||||
@app.get("/api/scan-status/{upload_id}")
|
||||
async def scan_status_sse(upload_id: str):
|
||||
try:
|
||||
uuid.UUID(upload_id)
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "Ungültige Upload-ID"}, status_code=400)
|
||||
|
||||
async def event_generator():
|
||||
seen = 0
|
||||
while True:
|
||||
entries = _scan_progress.get(upload_id, [])
|
||||
while seen < len(entries):
|
||||
entry = entries[seen]
|
||||
seen += 1
|
||||
|
||||
import json
|
||||
yield {"event": entry.get("stage", "status"), "data": json.dumps(entry)}
|
||||
|
||||
if entry.get("stage") in ("done", "error"):
|
||||
# Cleanup progress data
|
||||
_scan_progress.pop(upload_id, None)
|
||||
return
|
||||
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
return EventSourceResponse(event_generator())
|
||||
|
||||
|
||||
@app.get("/api/separator-pdf")
|
||||
async def separator_pdf():
|
||||
pdf_bytes = generate_separator_pdf()
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": "attachment; filename=Trennseite_LexOffice.pdf"},
|
||||
)
|
||||
Reference in New Issue
Block a user