Compare commits
4 Commits
main
..
921656090b
| Author | SHA1 | Date | |
|---|---|---|---|
| 921656090b | |||
| a43bfa80d6 | |||
| e221df2013 | |||
| 05f814c5b7 |
@@ -1,4 +0,0 @@
|
|||||||
data/
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
.env
|
|
||||||
+2
-26
@@ -1,39 +1,15 @@
|
|||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends libzbar0 && rm -rf /var/lib/apt/lists/*
|
||||||
libzbar0 \
|
|
||||||
# Chromium dependencies for Playwright
|
|
||||||
libglib2.0-0t64 \
|
|
||||||
libnss3 \
|
|
||||||
libnspr4 \
|
|
||||||
libatk1.0-0t64 \
|
|
||||||
libatk-bridge2.0-0t64 \
|
|
||||||
libcups2t64 \
|
|
||||||
libdrm2 \
|
|
||||||
libexpat1 \
|
|
||||||
libxcomposite1 \
|
|
||||||
libxdamage1 \
|
|
||||||
libxext6 \
|
|
||||||
libxfixes3 \
|
|
||||||
libxrandr2 \
|
|
||||||
libgbm1 \
|
|
||||||
libxkbcommon0 \
|
|
||||||
libpango-1.0-0 \
|
|
||||||
libcairo2 \
|
|
||||||
libasound2t64 \
|
|
||||||
libatspi2.0-0t64 \
|
|
||||||
fonts-liberation \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
RUN playwright install chromium
|
|
||||||
|
|
||||||
COPY app/ ./app/
|
COPY app/ ./app/
|
||||||
|
|
||||||
RUN mkdir -p /data/uploads /data/amazon_session
|
RUN mkdir -p /data/uploads
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
|||||||
@@ -1,196 +0,0 @@
|
|||||||
# Belegimport
|
|
||||||
|
|
||||||
Automatischer Import von Belegen (Rechnungen, Gutschriften) aus verschiedenen Quellen und Weiterleitung per SMTP an Buchhaltungssoftware (z.B. Buchhaltungsbutler).
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Scan-Upload**: PDF hochladen, automatische Trennung per QR-Code-Trennseiten
|
|
||||||
- **IMAP**: Automatischer Abruf von Belegen aus Email-Postfachern
|
|
||||||
- **SMB/Netzlaufwerk**: Automatischer Abruf von Belegen aus Netzwerkordnern
|
|
||||||
- **FTP / SFTP**: Automatischer Abruf von Belegen via FTP (passiv, unverschluesselt) oder SFTP (SSH)
|
|
||||||
- **Amazon Business**: Automatischer Abruf von Amazon-Rechnungen per API
|
|
||||||
- **Eingangs-/Ausgangsbelege**: Getrennte Import-Adressen fur Einkauf und Verkauf
|
|
||||||
- **Scheduler**: Automatischer Abruf in konfigurierbaren Intervallen
|
|
||||||
- **Verarbeitungslog**: Ubersicht aller importierten Belege mit Status
|
|
||||||
|
|
||||||
## Technologie
|
|
||||||
|
|
||||||
- Python 3.12, FastAPI, Jinja2, SQLite (aiosqlite)
|
|
||||||
- Docker / docker-compose
|
|
||||||
- Playwright (optional, fur Amazon Browser-Automation als Fallback)
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### Docker (empfohlen)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone <repository-url>
|
|
||||||
cd lex-office-belegimport-mail
|
|
||||||
sudo docker-compose up --build -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Die Webanwendung ist erreichbar unter: `http://localhost:8081`
|
|
||||||
|
|
||||||
### Konfiguration
|
|
||||||
|
|
||||||
Alle Einstellungen werden uber die Weboberflache vorgenommen:
|
|
||||||
|
|
||||||
1. **Einstellungen** (`/settings`): SMTP-Server, IMAP, SMB, Import-Emailadressen
|
|
||||||
2. **Plattformen** (`/platforms`): Amazon Business API-Zugangsdaten
|
|
||||||
3. **Scan-Upload** (`/`): Manueller PDF-Upload mit Belegart-Auswahl
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Amazon Business API Einrichtung
|
|
||||||
|
|
||||||
Die Amazon-Integration nutzt die offizielle Amazon Business API (Reconciliation + Document API) um Rechnungen automatisch abzurufen. Kein Browser-Login, keine CAPTCHAs, vollautomatisch.
|
|
||||||
|
|
||||||
### Voraussetzungen
|
|
||||||
|
|
||||||
- Amazon Business Konto (mit Business Prime)
|
|
||||||
- Zugang zum [Amazon Solution Provider Portal](https://solutionproviderportal.amazon.com/)
|
|
||||||
|
|
||||||
### Schritt 1: Als Entwickler registrieren
|
|
||||||
|
|
||||||
1. Offne das [Solution Provider Portal](https://solutionproviderportal.amazon.com/)
|
|
||||||
2. Wahle **"Private seller applications"** (fur eigene Integrationen)
|
|
||||||
3. Wahle **"Erstellen Sie Anwendungen, die SP-APIs verwenden"**
|
|
||||||
4. Fulle die Unternehmensdaten aus (Name, Handelsregisternummer, Adresse)
|
|
||||||
5. Verifiziere per SMS
|
|
||||||
|
|
||||||
### Schritt 2: Rollen auswahlen
|
|
||||||
|
|
||||||
Wahle folgende Rollen:
|
|
||||||
|
|
||||||
- **Abgleichen von Business-Einkaufen** (Reconciliation API)
|
|
||||||
- **Amazon Business-Bestellung** (Business Orders API)
|
|
||||||
|
|
||||||
### Schritt 3: Sicherheitskontrollen
|
|
||||||
|
|
||||||
Beantworte alle Sicherheitsfragen mit **"Ja"**.
|
|
||||||
|
|
||||||
Bei den Textfeldern:
|
|
||||||
- **Externe Parteien**: `Keine. Die Daten werden ausschliesslich intern fur die eigene Buchhaltung verwendet.`
|
|
||||||
- **Externe Quellen**: `Keine.`
|
|
||||||
|
|
||||||
### Schritt 4: App registrieren
|
|
||||||
|
|
||||||
Nach der Freischaltung (kann einige Tage dauern):
|
|
||||||
|
|
||||||
1. Im Developer Central: **"+ Neuen App-Client hinzufugen"**
|
|
||||||
2. Einstellungen:
|
|
||||||
- **App-Name**: `Beleg import` (oder eigener Name)
|
|
||||||
- **API-Typ**: `SP-API`
|
|
||||||
- **App-Typ**: `Produktion`
|
|
||||||
- **Amazon Business**: angehakt
|
|
||||||
- **Verkaufer**: nicht angehakt
|
|
||||||
- **Rollen**: Abgleichen von Business-Einkaufen + Amazon Business-Bestellung
|
|
||||||
- **RDT**: Nein
|
|
||||||
- **OAuth-Anmeldungs-URI**: `https://ihre-domain.de/api/amazon-oauth-callback`
|
|
||||||
- **OAuth-Umleitungs-URI**: `https://ihre-domain.de/api/amazon-oauth-callback`
|
|
||||||
|
|
||||||
3. Nach dem Speichern: **"Anmeldedaten fur Login mit Amazon" -> "Anzeigen"**
|
|
||||||
- Notiere **Client-ID** (`amzn1.application-oa2-client.xxxxx`)
|
|
||||||
- Notiere **Client-Sicherheitsschluessel** (`amzn1.oa2-cs.v1.xxxxx`)
|
|
||||||
|
|
||||||
4. Die **App-ID** (`amzn1.sp.solution.xxxxx`) steht in der App-Ubersicht unter dem App-Namen
|
|
||||||
|
|
||||||
> **Hinweis**: Die OAuth-Umleitungs-URI muss eine echte Domain mit Top-Level-Domain sein.
|
|
||||||
> `localhost` und `.local` Domains werden von Amazon nicht akzeptiert.
|
|
||||||
> Die URI muss nicht offentlich erreichbar sein - Amazon leitet nur den Browser des Benutzers dorthin weiter.
|
|
||||||
|
|
||||||
> **Fehler SPSA0404**: Falls beim Autorisieren der Fehler "Keine unterstuetzte Geschaeftseinheit" erscheint,
|
|
||||||
> muss die Autorisierung uber den OAuth-Flow (Website) statt uber Self-Authorization erfolgen.
|
|
||||||
> Der Belegimport unterstutzt dies automatisch.
|
|
||||||
|
|
||||||
### Schritt 5: Im Belegimport konfigurieren
|
|
||||||
|
|
||||||
1. Offne die Plattformen-Seite im Belegimport
|
|
||||||
2. Setze **Abruf-Modus** auf **"API (empfohlen)"**
|
|
||||||
3. Trage ein:
|
|
||||||
- **App-ID**: `amzn1.sp.solution.xxxxx`
|
|
||||||
- **Client-ID**: `amzn1.application-oa2-client.xxxxx`
|
|
||||||
- **Client-Sicherheitsschluessel**: Der Secret-Wert
|
|
||||||
4. **Einstellungen speichern**
|
|
||||||
5. Klicke **"Bei Amazon autorisieren"**
|
|
||||||
6. Melde dich bei Amazon an und erlaube den Zugriff
|
|
||||||
7. Kopiere den `spapi_oauth_code` (oder die ganze URL) aus der Browser-Adressleiste
|
|
||||||
8. Trage den Code im Belegimport ein und klicke **"Token tauschen"**
|
|
||||||
9. Status sollte auf **"API autorisiert"** wechseln
|
|
||||||
|
|
||||||
### Schritt 6: Rechnungen abrufen
|
|
||||||
|
|
||||||
- **Manuell**: Klicke "Jetzt Rechnungen abrufen"
|
|
||||||
- **Automatisch**: Aktiviere den Scheduler unter Einstellungen (z.B. alle 60 Minuten)
|
|
||||||
|
|
||||||
Die Rechnungen werden als PDF per SMTP an die konfigurierte Eingangsbeleg-Adresse gesendet.
|
|
||||||
Bereits abgerufene Rechnungen werden automatisch ubersprungen.
|
|
||||||
|
|
||||||
### OAuth Redirect URI (lokale Installation)
|
|
||||||
|
|
||||||
Da Amazon keine `localhost`-URIs akzeptiert, gibt es zwei Optionen:
|
|
||||||
|
|
||||||
**Option A: Eigene Domain verwenden (empfohlen)**
|
|
||||||
|
|
||||||
Trage eine echte Domain ein (z.B. `https://ihre-domain.de/api/amazon-oauth-callback`).
|
|
||||||
Nach der Amazon-Autorisierung leitet der Browser dorthin weiter - die Seite ladt nicht,
|
|
||||||
aber der Auth-Code steht in der URL-Leiste. Diesen Code im Belegimport eintragen.
|
|
||||||
|
|
||||||
**Option B: /etc/hosts Eintrag**
|
|
||||||
|
|
||||||
Falls der Server lokal erreichbar sein soll:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# In /etc/hosts eintragen:
|
|
||||||
127.0.0.1 app.belegimport.de
|
|
||||||
```
|
|
||||||
|
|
||||||
Dann in der Amazon App als Redirect URI eintragen:
|
|
||||||
`https://app.belegimport.de/api/amazon-oauth-callback`
|
|
||||||
|
|
||||||
> **Achtung**: Amazon pruft ob die Domain eine Top-Level-Domain hat.
|
|
||||||
> `.local` funktioniert nicht, aber `.de` schon.
|
|
||||||
|
|
||||||
### Umgebungsvariablen
|
|
||||||
|
|
||||||
In `docker-compose.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
environment:
|
|
||||||
- OAUTH_REDIRECT_BASE=https://ihre-domain.de # Muss zur Amazon App passen
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Eingangs- und Ausgangsbelege
|
|
||||||
|
|
||||||
Der Belegimport unterscheidet zwischen:
|
|
||||||
|
|
||||||
- **Eingangsbelege (Einkauf)**: Rechnungen die Sie von Lieferanten erhalten
|
|
||||||
- **Ausgangsbelege (Verkauf/Gutschrift)**: Rechnungen die Sie an Kunden senden
|
|
||||||
|
|
||||||
Fur beide Typen konnen separate Import-Emailadressen konfiguriert werden (z.B. fur Buchhaltungsbutler).
|
|
||||||
Amazon-Rechnungen werden automatisch als Eingangsbelege klassifiziert.
|
|
||||||
|
|
||||||
Bei IMAP und SMB konnen jeweils getrennte Quell- und Verarbeitungsordner fur Eingangs- und Ausgangsbelege konfiguriert werden.
|
|
||||||
|
|
||||||
Beim Scan-Upload kann die Belegart per Radio-Button ausgewahlt werden.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verarbeitungslog
|
|
||||||
|
|
||||||
Unter `/log` werden alle verarbeiteten Belege angezeigt mit:
|
|
||||||
|
|
||||||
- Zeitpunkt, Betreff, Absender
|
|
||||||
- Belegart (Eingang/Ausgang)
|
|
||||||
- Anzahl Anhange
|
|
||||||
- Status (OK/Fehler)
|
|
||||||
- Fehlermeldung (falls vorhanden)
|
|
||||||
- SMTP-Protokoll (anzeigbar)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Lizenz
|
|
||||||
|
|
||||||
Privates Projekt.
|
|
||||||
@@ -1,515 +0,0 @@
|
|||||||
"""Amazon Business API client using SP-API (Reconciliation + Document API).
|
|
||||||
|
|
||||||
This module provides API-based invoice retrieval as an alternative to browser automation.
|
|
||||||
Uses OAuth2 with LWA (Login with Amazon) for authentication.
|
|
||||||
|
|
||||||
Document API workflow (EU):
|
|
||||||
1. POST /reports/.../reports → reportId
|
|
||||||
2. GET /reports/.../reports/{reportId} → poll until DONE → reportDocumentId
|
|
||||||
3. GET /reports/.../documents/{reportDocumentId} → presigned URL
|
|
||||||
4. Download + decompress (gzip then zip) → PDF
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import gzip
|
|
||||||
import io
|
|
||||||
import logging
|
|
||||||
import urllib.parse
|
|
||||||
import zipfile
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from app.database import get_settings, save_settings, add_log_entry, is_invoice_downloaded, mark_invoice_downloaded
|
|
||||||
from app.mail_processor import _connect_smtp, _build_forward_email, _send_with_log
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Amazon LWA (Login with Amazon) endpoints
|
|
||||||
LWA_TOKEN_URL = "https://api.amazon.com/auth/o2/token"
|
|
||||||
|
|
||||||
# Amazon Business OAuth consent URLs per domain (NOT sellercentral!)
|
|
||||||
AB_OAUTH_URLS = {
|
|
||||||
"amazon.de": "https://www.amazon.de/b2b/abws/oauth",
|
|
||||||
"amazon.at": "https://www.amazon.de/b2b/abws/oauth", # AT uses DE
|
|
||||||
"amazon.fr": "https://www.amazon.fr/b2b/abws/oauth",
|
|
||||||
"amazon.it": "https://www.amazon.it/b2b/abws/oauth",
|
|
||||||
"amazon.es": "https://www.amazon.es/b2b/abws/oauth",
|
|
||||||
"amazon.co.uk": "https://www.amazon.co.uk/b2b/abws/oauth",
|
|
||||||
"amazon.com": "https://www.amazon.com/b2b/abws/oauth",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Amazon Business API endpoints per region
|
|
||||||
AB_API_ENDPOINTS = {
|
|
||||||
"eu": "https://eu.business-api.amazon.com",
|
|
||||||
"na": "https://na.business-api.amazon.com",
|
|
||||||
}
|
|
||||||
|
|
||||||
# API versions
|
|
||||||
RECONCILIATION_VERSION = "2021-01-08"
|
|
||||||
REPORTS_VERSION = "2021-09-30"
|
|
||||||
|
|
||||||
# Domain to region mapping
|
|
||||||
DOMAIN_REGION = {
|
|
||||||
"amazon.de": "eu",
|
|
||||||
"amazon.at": "eu",
|
|
||||||
"amazon.fr": "eu",
|
|
||||||
"amazon.it": "eu",
|
|
||||||
"amazon.es": "eu",
|
|
||||||
"amazon.co.uk": "eu",
|
|
||||||
"amazon.com": "na",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Domain to marketplace ID
|
|
||||||
DOMAIN_MARKETPLACE = {
|
|
||||||
"amazon.de": "A1PA6795UKMFR9",
|
|
||||||
"amazon.at": "A2NODRKZP88ZB9",
|
|
||||||
"amazon.fr": "A13V1IB3VIYZZH",
|
|
||||||
"amazon.it": "APJ6JRA9NG5V4",
|
|
||||||
"amazon.es": "A1RKKUPIHCS9HS",
|
|
||||||
"amazon.co.uk": "A1F83G8C2ARO7P",
|
|
||||||
"amazon.com": "ATVPDKIKX0DER",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_oauth_authorize_url(application_id: str, redirect_uri: str, domain: str = "amazon.de", state: str = "") -> str:
|
|
||||||
"""Generate the OAuth authorization URL for Amazon Business API consent."""
|
|
||||||
base_url = AB_OAUTH_URLS.get(domain, AB_OAUTH_URLS["amazon.de"])
|
|
||||||
params = {
|
|
||||||
"applicationId": application_id,
|
|
||||||
"state": state or "auth",
|
|
||||||
"redirect_uri": redirect_uri,
|
|
||||||
}
|
|
||||||
return f"{base_url}?{urllib.parse.urlencode(params)}"
|
|
||||||
|
|
||||||
|
|
||||||
async def exchange_auth_code(code: str, client_id: str, client_secret: str, redirect_uri: str) -> dict:
|
|
||||||
"""Exchange authorization code for refresh token via LWA."""
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.post(LWA_TOKEN_URL, data={
|
|
||||||
"grant_type": "authorization_code",
|
|
||||||
"code": code,
|
|
||||||
"client_id": client_id,
|
|
||||||
"client_secret": client_secret,
|
|
||||||
"redirect_uri": redirect_uri,
|
|
||||||
})
|
|
||||||
if resp.status_code != 200:
|
|
||||||
logger.error(f"LWA Token-Exchange fehlgeschlagen: {resp.status_code} {resp.text}")
|
|
||||||
return {"error": f"Token-Exchange fehlgeschlagen: {resp.status_code} - {resp.text}"}
|
|
||||||
data = resp.json()
|
|
||||||
logger.info("LWA Token-Exchange erfolgreich")
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
async def get_access_token(client_id: str, client_secret: str, refresh_token: str) -> str | None:
|
|
||||||
"""Get a fresh access token using the refresh token."""
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.post(LWA_TOKEN_URL, data={
|
|
||||||
"grant_type": "refresh_token",
|
|
||||||
"refresh_token": refresh_token,
|
|
||||||
"client_id": client_id,
|
|
||||||
"client_secret": client_secret,
|
|
||||||
})
|
|
||||||
if resp.status_code != 200:
|
|
||||||
logger.error(f"Access-Token-Refresh fehlgeschlagen: {resp.status_code} {resp.text}")
|
|
||||||
return None
|
|
||||||
data = resp.json()
|
|
||||||
return data.get("access_token")
|
|
||||||
|
|
||||||
|
|
||||||
async def check_api_configured() -> dict:
|
|
||||||
"""Check if API credentials are configured and valid."""
|
|
||||||
settings = await get_settings()
|
|
||||||
client_id = settings.get("amazon_client_id", "")
|
|
||||||
client_secret = settings.get("amazon_client_secret", "")
|
|
||||||
refresh_token = settings.get("amazon_refresh_token", "")
|
|
||||||
|
|
||||||
if not client_id or not client_secret:
|
|
||||||
return {"configured": False, "authorized": False, "error": "Client-ID oder Client-Secret fehlt"}
|
|
||||||
|
|
||||||
if not refresh_token:
|
|
||||||
return {"configured": True, "authorized": False, "error": "Noch nicht autorisiert (Refresh-Token fehlt)"}
|
|
||||||
|
|
||||||
# Try to get an access token to verify credentials
|
|
||||||
access_token = await get_access_token(client_id, client_secret, refresh_token)
|
|
||||||
if not access_token:
|
|
||||||
return {"configured": True, "authorized": False, "error": "Autorisierung abgelaufen - bitte erneut autorisieren"}
|
|
||||||
|
|
||||||
return {"configured": True, "authorized": True}
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_api_client(settings: dict) -> tuple[httpx.AsyncClient, str] | None:
|
|
||||||
"""Create an authenticated API client. Returns (client, region) or None."""
|
|
||||||
client_id = settings.get("amazon_client_id", "")
|
|
||||||
client_secret = settings.get("amazon_client_secret", "")
|
|
||||||
refresh_token = settings.get("amazon_refresh_token", "")
|
|
||||||
|
|
||||||
if not all([client_id, client_secret, refresh_token]):
|
|
||||||
return None
|
|
||||||
|
|
||||||
access_token = await get_access_token(client_id, client_secret, refresh_token)
|
|
||||||
if not access_token:
|
|
||||||
return None
|
|
||||||
|
|
||||||
domain = settings.get("amazon_domain", "amazon.de")
|
|
||||||
region = DOMAIN_REGION.get(domain, "eu")
|
|
||||||
|
|
||||||
client = httpx.AsyncClient(
|
|
||||||
base_url=AB_API_ENDPOINTS.get(region, AB_API_ENDPOINTS["eu"]),
|
|
||||||
headers={
|
|
||||||
"x-amz-access-token": access_token,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"user-agent": "Belegimport/1.0 (Language=Python/3.12)",
|
|
||||||
},
|
|
||||||
timeout=30.0,
|
|
||||||
)
|
|
||||||
return client, region
|
|
||||||
|
|
||||||
|
|
||||||
async def get_transactions(settings: dict, since_date: datetime) -> list[dict]:
|
|
||||||
"""Get transactions via Reconciliation API."""
|
|
||||||
result = await _get_api_client(settings)
|
|
||||||
if not result:
|
|
||||||
return []
|
|
||||||
|
|
||||||
client, region = result
|
|
||||||
|
|
||||||
transactions = []
|
|
||||||
try:
|
|
||||||
# feedEndDate must not exceed current UTC time
|
|
||||||
now_utc = datetime.utcnow()
|
|
||||||
params = {
|
|
||||||
"feedStartDate": since_date.strftime("%Y-%m-%dT00:00:00Z"),
|
|
||||||
"feedEndDate": now_utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
||||||
}
|
|
||||||
|
|
||||||
next_token = None
|
|
||||||
page = 0
|
|
||||||
while True:
|
|
||||||
page += 1
|
|
||||||
if next_token:
|
|
||||||
params["nextPageToken"] = next_token
|
|
||||||
|
|
||||||
logger.info(f"Amazon API: Reconciliation-Abfrage Seite {page}...")
|
|
||||||
resp = await client.get(
|
|
||||||
f"/reconciliation/{RECONCILIATION_VERSION}/transactions",
|
|
||||||
params=params,
|
|
||||||
)
|
|
||||||
|
|
||||||
if resp.status_code != 200:
|
|
||||||
logger.error(f"Amazon API: Reconciliation fehlgeschlagen: {resp.status_code} {resp.text}")
|
|
||||||
break
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
page_transactions = data.get("transactions", [])
|
|
||||||
transactions.extend(page_transactions)
|
|
||||||
logger.info(f"Amazon API: Seite {page}: {len(page_transactions)} Transaktionen")
|
|
||||||
|
|
||||||
next_token = data.get("nextPageToken")
|
|
||||||
if not next_token:
|
|
||||||
break
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Amazon API: Reconciliation-Fehler: {e}")
|
|
||||||
finally:
|
|
||||||
await client.aclose()
|
|
||||||
|
|
||||||
logger.info(f"Amazon API: {len(transactions)} Transaktionen gesamt")
|
|
||||||
return transactions
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_invoice_report(client: httpx.AsyncClient, order_id: str, marketplace_id: str) -> str | None:
|
|
||||||
"""Step 1: Create a report request for invoice PDF."""
|
|
||||||
body = {
|
|
||||||
"reportType": "GET_AB_INVOICE_PDF",
|
|
||||||
"marketplaceIds": [marketplace_id],
|
|
||||||
"reportOptions": {
|
|
||||||
"orderId": order_id,
|
|
||||||
"documentType": "Invoice",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
resp = await client.post(f"/reports/{REPORTS_VERSION}/reports", json=body)
|
|
||||||
if resp.status_code in (200, 202):
|
|
||||||
data = resp.json()
|
|
||||||
report_id = data.get("reportId")
|
|
||||||
logger.info(f"Amazon API: Report erstellt für {order_id}: {report_id}")
|
|
||||||
return report_id
|
|
||||||
else:
|
|
||||||
logger.warning(f"Amazon API: Report-Erstellung fehlgeschlagen für {order_id}: {resp.status_code} {resp.text}")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Amazon API: Report-Erstellung Fehler: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _poll_report_status(client: httpx.AsyncClient, report_id: str, max_wait: int = 120) -> str | None:
|
|
||||||
"""Step 2: Poll report status until DONE. Returns reportDocumentId."""
|
|
||||||
for i in range(max_wait // 15 + 1):
|
|
||||||
try:
|
|
||||||
resp = await client.get(f"/reports/{REPORTS_VERSION}/reports/{report_id}")
|
|
||||||
if resp.status_code != 200:
|
|
||||||
logger.warning(f"Amazon API: Report-Status fehlgeschlagen: {resp.status_code}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
status = data.get("processingStatus", "")
|
|
||||||
|
|
||||||
if status == "DONE":
|
|
||||||
doc_id = data.get("reportDocumentId")
|
|
||||||
logger.info(f"Amazon API: Report {report_id} fertig: documentId={doc_id}")
|
|
||||||
return doc_id
|
|
||||||
elif status in ("CANCELLED", "FATAL"):
|
|
||||||
logger.warning(f"Amazon API: Report {report_id} fehlgeschlagen: {status}")
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
logger.debug(f"Amazon API: Report {report_id} Status: {status}, warte...")
|
|
||||||
await asyncio.sleep(15)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Amazon API: Report-Status Fehler: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
logger.warning(f"Amazon API: Report {report_id} Timeout nach {max_wait}s")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _download_report_document(client: httpx.AsyncClient, document_id: str) -> bytes | None:
|
|
||||||
"""Step 3: Get presigned URL and download + decompress the PDF."""
|
|
||||||
try:
|
|
||||||
resp = await client.get(f"/reports/{REPORTS_VERSION}/documents/{document_id}")
|
|
||||||
if resp.status_code != 200:
|
|
||||||
logger.warning(f"Amazon API: Document-URL fehlgeschlagen: {resp.status_code}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
url = data.get("url", "")
|
|
||||||
compression = data.get("compressionAlgorithm", "")
|
|
||||||
|
|
||||||
if not url:
|
|
||||||
logger.warning(f"Amazon API: Keine Download-URL für Document {document_id}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Download the document (presigned S3 URL, expires in 5 min)
|
|
||||||
async with httpx.AsyncClient(timeout=60.0) as dl_client:
|
|
||||||
dl_resp = await dl_client.get(url)
|
|
||||||
if dl_resp.status_code != 200:
|
|
||||||
logger.warning(f"Amazon API: Document-Download fehlgeschlagen: {dl_resp.status_code}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
content = dl_resp.content
|
|
||||||
|
|
||||||
# Decompress: EU documents are gzip-compressed, then the content is a zip file
|
|
||||||
if compression == "GZIP" or content[:2] == b'\x1f\x8b':
|
|
||||||
try:
|
|
||||||
content = gzip.decompress(content)
|
|
||||||
except Exception:
|
|
||||||
pass # might not be gzipped
|
|
||||||
|
|
||||||
# Check if it's a zip file containing the PDF
|
|
||||||
if content[:2] == b'PK':
|
|
||||||
try:
|
|
||||||
with zipfile.ZipFile(io.BytesIO(content)) as zf:
|
|
||||||
for name in zf.namelist():
|
|
||||||
if name.lower().endswith('.pdf'):
|
|
||||||
content = zf.read(name)
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
pass # might not be a zip
|
|
||||||
|
|
||||||
# Verify it's a PDF
|
|
||||||
if content[:4] == b'%PDF':
|
|
||||||
logger.info(f"Amazon API: PDF heruntergeladen: {len(content)} Bytes")
|
|
||||||
return content
|
|
||||||
else:
|
|
||||||
logger.warning(f"Amazon API: Heruntergeladenes Dokument ist kein PDF (starts: {content[:20]})")
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Amazon API: Document-Download Fehler: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def download_invoice(settings: dict, order_id: str) -> bytes | None:
|
|
||||||
"""Download invoice PDF via Document API (3-step async process)."""
|
|
||||||
result = await _get_api_client(settings)
|
|
||||||
if not result:
|
|
||||||
return None
|
|
||||||
|
|
||||||
client, region = result
|
|
||||||
domain = settings.get("amazon_domain", "amazon.de")
|
|
||||||
marketplace_id = DOMAIN_MARKETPLACE.get(domain, DOMAIN_MARKETPLACE["amazon.de"])
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Step 1: Create report
|
|
||||||
report_id = await _create_invoice_report(client, order_id, marketplace_id)
|
|
||||||
if not report_id:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Step 2: Poll until done
|
|
||||||
document_id = await _poll_report_status(client, report_id)
|
|
||||||
if not document_id:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Step 3: Download document
|
|
||||||
return await _download_report_document(client, document_id)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Amazon API: Invoice-Download-Fehler für {order_id}: {e}")
|
|
||||||
return None
|
|
||||||
finally:
|
|
||||||
await client.aclose()
|
|
||||||
|
|
||||||
|
|
||||||
async def process_amazon_api() -> dict:
|
|
||||||
"""Process Amazon invoices via API (Reconciliation + Document API)."""
|
|
||||||
settings = await get_settings()
|
|
||||||
|
|
||||||
if settings.get("amazon_enabled") != "true":
|
|
||||||
return {"processed": 0, "skipped": 0, "errors": 0}
|
|
||||||
|
|
||||||
# Check API credentials
|
|
||||||
status = await check_api_configured()
|
|
||||||
if not status.get("authorized"):
|
|
||||||
error_msg = status.get("error", "API nicht konfiguriert")
|
|
||||||
logger.warning(f"Amazon API: {error_msg}")
|
|
||||||
return {"processed": 0, "skipped": 0, "errors": 0, "error": error_msg}
|
|
||||||
|
|
||||||
domain = settings.get("amazon_domain", "amazon.de")
|
|
||||||
|
|
||||||
# Determine date range
|
|
||||||
since_str = settings.get("amazon_since_date", "")
|
|
||||||
if since_str:
|
|
||||||
try:
|
|
||||||
since_date = datetime.strptime(since_str, "%Y-%m-%d")
|
|
||||||
except ValueError:
|
|
||||||
since_date = datetime.now() - timedelta(days=30)
|
|
||||||
else:
|
|
||||||
since_date = datetime.now() - timedelta(days=30)
|
|
||||||
|
|
||||||
logger.info(f"Amazon API: Import gestartet: domain={domain}, seit={since_date.strftime('%Y-%m-%d')}")
|
|
||||||
|
|
||||||
# Connect SMTP
|
|
||||||
import_email = settings.get("import_email_eingang") or settings.get("import_email", "")
|
|
||||||
if not import_email:
|
|
||||||
error_msg = "Keine Import-Email für Eingangsbelege konfiguriert"
|
|
||||||
logger.error(f"Amazon API: {error_msg}")
|
|
||||||
await add_log_entry("Amazon-Import", f"Amazon ({domain})", 0, "error", error_msg, beleg_type="eingang")
|
|
||||||
return {"processed": 0, "skipped": 0, "errors": 1, "error": error_msg}
|
|
||||||
|
|
||||||
smtp = _connect_smtp(settings)
|
|
||||||
if not smtp:
|
|
||||||
error_msg = "SMTP-Verbindung fehlgeschlagen"
|
|
||||||
logger.error(f"Amazon API: {error_msg}")
|
|
||||||
await add_log_entry("Amazon-Import", f"Amazon ({domain})", 0, "error", error_msg, beleg_type="eingang")
|
|
||||||
return {"processed": 0, "skipped": 0, "errors": 1, "error": error_msg}
|
|
||||||
|
|
||||||
processed = 0
|
|
||||||
skipped = 0
|
|
||||||
errors = 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get transactions via Reconciliation API
|
|
||||||
transactions = await get_transactions(settings, since_date)
|
|
||||||
|
|
||||||
if not transactions:
|
|
||||||
logger.info("Amazon API: Keine Transaktionen gefunden")
|
|
||||||
await save_settings({"amazon_last_sync": datetime.now().strftime("%Y-%m-%d %H:%M")})
|
|
||||||
await add_log_entry(
|
|
||||||
"Amazon-Import (API)", f"Amazon ({domain})", 0,
|
|
||||||
"success", "Keine neuen Rechnungen gefunden", beleg_type="eingang",
|
|
||||||
)
|
|
||||||
smtp.quit()
|
|
||||||
return {"processed": 0, "skipped": 0, "errors": 0}
|
|
||||||
|
|
||||||
# Extract unique orders with their line items
|
|
||||||
orders = {}
|
|
||||||
for txn in transactions:
|
|
||||||
line_items = txn.get("transactionLineItems", [])
|
|
||||||
for item in line_items:
|
|
||||||
oid = item.get("orderId", "")
|
|
||||||
if oid and oid not in orders:
|
|
||||||
orders[oid] = {
|
|
||||||
"orderId": oid,
|
|
||||||
"invoiceNumber": txn.get("invoiceNumber", ""),
|
|
||||||
"transactionDate": txn.get("transactionDate", ""),
|
|
||||||
}
|
|
||||||
# Fallback: if no line items, use transaction-level orderId
|
|
||||||
if not line_items:
|
|
||||||
oid = txn.get("orderId", "")
|
|
||||||
if oid and oid not in orders:
|
|
||||||
orders[oid] = {
|
|
||||||
"orderId": oid,
|
|
||||||
"invoiceNumber": txn.get("invoiceNumber", ""),
|
|
||||||
"transactionDate": txn.get("transactionDate", ""),
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(f"Amazon API: {len(orders)} eindeutige Bestellungen gefunden")
|
|
||||||
|
|
||||||
for oid, order_info in orders.items():
|
|
||||||
# Check if already downloaded
|
|
||||||
if await is_invoice_downloaded(oid, oid):
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Download invoice PDF
|
|
||||||
pdf_data = await download_invoice(settings, oid)
|
|
||||||
|
|
||||||
if pdf_data:
|
|
||||||
# Save debug copy if enabled
|
|
||||||
if settings.get("debug_save_amazon_pdfs") == "true":
|
|
||||||
debug_dir = Path("/data/uploads") / "amazon_invoices"
|
|
||||||
debug_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
debug_path = debug_dir / f"Amazon_Rechnung_{oid}.pdf"
|
|
||||||
debug_path.write_bytes(pdf_data)
|
|
||||||
logger.info(f"Amazon API: Debug-PDF gespeichert: {debug_path}")
|
|
||||||
|
|
||||||
# Send via SMTP
|
|
||||||
filename = f"Amazon_Rechnung_{oid}.pdf"
|
|
||||||
subject = f"Amazon Rechnung - {oid}"
|
|
||||||
from_addr = settings.get("smtp_username", "belegimport@local")
|
|
||||||
msg = _build_forward_email(
|
|
||||||
from_addr=from_addr,
|
|
||||||
to_addr=import_email,
|
|
||||||
original_subject=subject,
|
|
||||||
original_from=f"Amazon ({domain})",
|
|
||||||
attachments=[(filename, pdf_data)],
|
|
||||||
)
|
|
||||||
smtp_log = _send_with_log(smtp, msg)
|
|
||||||
await add_log_entry(
|
|
||||||
subject, f"Amazon ({domain})", 1,
|
|
||||||
"success", "", import_email, smtp_log, beleg_type="eingang",
|
|
||||||
)
|
|
||||||
await mark_invoice_downloaded(oid, oid)
|
|
||||||
processed += 1
|
|
||||||
logger.info(f"Amazon API: Rechnung für {oid} gesendet")
|
|
||||||
else:
|
|
||||||
# No invoice available for this order
|
|
||||||
await mark_invoice_downloaded(oid, oid)
|
|
||||||
skipped += 1
|
|
||||||
logger.debug(f"Amazon API: Keine Rechnung für {oid}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Amazon API: Import-Fehler: {e}", exc_info=True)
|
|
||||||
errors += 1
|
|
||||||
await add_log_entry(
|
|
||||||
"Amazon-Import (API)", f"Amazon ({domain})", 0,
|
|
||||||
"error", str(e), beleg_type="eingang",
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
smtp.quit()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
await save_settings({"amazon_last_sync": datetime.now().strftime("%Y-%m-%d %H:%M")})
|
|
||||||
|
|
||||||
if processed > 0 or errors > 0:
|
|
||||||
summary = f"{processed} verarbeitet, {skipped} übersprungen, {errors} Fehler"
|
|
||||||
await add_log_entry(
|
|
||||||
"Amazon-Import (API, Zusammenfassung)", f"Amazon ({domain})", processed,
|
|
||||||
"success" if errors == 0 else "warning", summary, beleg_type="eingang",
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Amazon API: Import fertig: {processed} verarbeitet, {skipped} übersprungen, {errors} Fehler")
|
|
||||||
return {"processed": processed, "skipped": skipped, "errors": errors}
|
|
||||||
File diff suppressed because it is too large
Load Diff
+10
-270
@@ -1,16 +1,12 @@
|
|||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
DB_PATH = os.environ.get("DB_PATH", "/data/belegimport.db")
|
DB_PATH = os.environ.get("DB_PATH", "/data/belegimport.db")
|
||||||
SCHEMA_VERSION = 9
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_fernet = None
|
_fernet = None
|
||||||
|
|
||||||
ENCRYPTED_KEYS = {"imap_password", "smtp_password", "smb_password", "amazon_password", "amazon_client_secret", "amazon_refresh_token", "ftp_password"}
|
ENCRYPTED_KEYS = {"imap_password", "smtp_password", "smb_password"}
|
||||||
|
|
||||||
DEFAULT_SETTINGS = {
|
DEFAULT_SETTINGS = {
|
||||||
"imap_server": "",
|
"imap_server": "",
|
||||||
@@ -23,13 +19,9 @@ DEFAULT_SETTINGS = {
|
|||||||
"smtp_ssl": "starttls",
|
"smtp_ssl": "starttls",
|
||||||
"smtp_username": "",
|
"smtp_username": "",
|
||||||
"smtp_password": "",
|
"smtp_password": "",
|
||||||
"import_email": "",
|
"lexoffice_email": "",
|
||||||
"import_email_eingang": "",
|
|
||||||
"import_email_ausgang": "",
|
|
||||||
"source_folder": "Rechnungen",
|
"source_folder": "Rechnungen",
|
||||||
"processed_folder": "Rechnungen/Verarbeitet",
|
"processed_folder": "Rechnungen/Verarbeitet",
|
||||||
"source_folder_ausgang": "",
|
|
||||||
"processed_folder_ausgang": "",
|
|
||||||
"interval_minutes": "5",
|
"interval_minutes": "5",
|
||||||
"scheduler_enabled": "false",
|
"scheduler_enabled": "false",
|
||||||
"fetch_since_date": "",
|
"fetch_since_date": "",
|
||||||
@@ -43,36 +35,7 @@ DEFAULT_SETTINGS = {
|
|||||||
"smb_share": "",
|
"smb_share": "",
|
||||||
"smb_source_path": "",
|
"smb_source_path": "",
|
||||||
"smb_processed_path": "Verarbeitet",
|
"smb_processed_path": "Verarbeitet",
|
||||||
"smb_source_path_ausgang": "",
|
|
||||||
"smb_processed_path_ausgang": "",
|
|
||||||
"smb_mode": "forward",
|
"smb_mode": "forward",
|
||||||
# FTP / SFTP
|
|
||||||
"ftp_enabled": "false",
|
|
||||||
"ftp_protocol": "sftp", # "sftp" or "ftp"
|
|
||||||
"ftp_server": "",
|
|
||||||
"ftp_port": "22",
|
|
||||||
"ftp_username": "",
|
|
||||||
"ftp_password": "",
|
|
||||||
"ftp_source_path": "",
|
|
||||||
"ftp_processed_path": "Verarbeitet",
|
|
||||||
"ftp_source_path_ausgang": "",
|
|
||||||
"ftp_processed_path_ausgang": "",
|
|
||||||
"ftp_mode": "forward",
|
|
||||||
# Amazon
|
|
||||||
"amazon_enabled": "false",
|
|
||||||
"amazon_email": "",
|
|
||||||
"amazon_password": "",
|
|
||||||
"amazon_domain": "amazon.de",
|
|
||||||
"amazon_last_sync": "",
|
|
||||||
"amazon_since_date": "",
|
|
||||||
# Amazon API (SP-API / Business API)
|
|
||||||
"amazon_app_id": "", # amzn1.sp.solution.xxxxx (from Developer Portal)
|
|
||||||
"amazon_client_id": "", # amzn1.application-oa2-client.xxxxx (LWA Client ID)
|
|
||||||
"amazon_client_secret": "", # LWA Client Secret
|
|
||||||
"amazon_refresh_token": "",
|
|
||||||
"amazon_mode": "browser", # "browser" or "api"
|
|
||||||
# Debug
|
|
||||||
"debug_save_amazon_pdfs": "false",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -115,157 +78,9 @@ def _decrypt(fernet: Fernet, value: str) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
async def _get_schema_version(db: aiosqlite.Connection) -> int:
|
|
||||||
"""Read current schema version from DB. Returns 0 if not set."""
|
|
||||||
try:
|
|
||||||
cursor = await db.execute(
|
|
||||||
"SELECT value FROM settings WHERE key = 'schema_version'"
|
|
||||||
)
|
|
||||||
row = await cursor.fetchone()
|
|
||||||
return int(row[0]) if row else 0
|
|
||||||
except Exception:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
async def _set_schema_version(db: aiosqlite.Connection, version: int):
|
|
||||||
await db.execute(
|
|
||||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('schema_version', ?)",
|
|
||||||
(str(version),),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_migrations(db: aiosqlite.Connection, current_version: int):
|
|
||||||
"""Run all pending migrations sequentially."""
|
|
||||||
|
|
||||||
if current_version < 1:
|
|
||||||
logger.info("Migration v1: Initiale Tabellenstruktur")
|
|
||||||
# v1: Base tables (idempotent via IF NOT EXISTS)
|
|
||||||
await db.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL DEFAULT ''
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
await db.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS processing_log (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
timestamp TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
|
||||||
email_subject TEXT,
|
|
||||||
email_from TEXT,
|
|
||||||
attachments_count INTEGER DEFAULT 0,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
error_message TEXT,
|
|
||||||
sent_to TEXT DEFAULT '',
|
|
||||||
smtp_log TEXT DEFAULT '',
|
|
||||||
beleg_type TEXT DEFAULT 'eingang'
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
await db.commit()
|
|
||||||
await _set_schema_version(db, 1)
|
|
||||||
|
|
||||||
if current_version < 2:
|
|
||||||
logger.info("Migration v2: lexoffice_email -> import_email")
|
|
||||||
# v2: Rename lexoffice_email -> import_email
|
|
||||||
cursor = await db.execute(
|
|
||||||
"SELECT value FROM settings WHERE key = 'lexoffice_email'"
|
|
||||||
)
|
|
||||||
row = await cursor.fetchone()
|
|
||||||
if row and row[0]:
|
|
||||||
# Copy value to import_email if it's empty
|
|
||||||
cursor2 = await db.execute(
|
|
||||||
"SELECT value FROM settings WHERE key = 'import_email'"
|
|
||||||
)
|
|
||||||
row2 = await cursor2.fetchone()
|
|
||||||
if not row2 or not row2[0]:
|
|
||||||
await db.execute(
|
|
||||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('import_email', ?)",
|
|
||||||
(row[0],),
|
|
||||||
)
|
|
||||||
logger.info(" lexoffice_email Wert nach import_email übertragen")
|
|
||||||
await db.execute("DELETE FROM settings WHERE key = 'lexoffice_email'")
|
|
||||||
await db.commit()
|
|
||||||
await _set_schema_version(db, 2)
|
|
||||||
|
|
||||||
if current_version < 3:
|
|
||||||
logger.info("Migration v3: Amazon-Plattform hinzugefügt")
|
|
||||||
await db.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS amazon_downloaded (
|
|
||||||
order_id TEXT PRIMARY KEY,
|
|
||||||
downloaded_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
await db.commit()
|
|
||||||
await _set_schema_version(db, 3)
|
|
||||||
|
|
||||||
if current_version < 4:
|
|
||||||
logger.info("Migration v4: sent_to Spalte im Verarbeitungslog")
|
|
||||||
await db.execute("""
|
|
||||||
ALTER TABLE processing_log ADD COLUMN sent_to TEXT DEFAULT ''
|
|
||||||
""")
|
|
||||||
await db.commit()
|
|
||||||
await _set_schema_version(db, 4)
|
|
||||||
|
|
||||||
if current_version < 5:
|
|
||||||
logger.info("Migration v5: SMTP-Protokoll im Verarbeitungslog")
|
|
||||||
await db.execute("""
|
|
||||||
ALTER TABLE processing_log ADD COLUMN smtp_log TEXT DEFAULT ''
|
|
||||||
""")
|
|
||||||
await db.commit()
|
|
||||||
await _set_schema_version(db, 5)
|
|
||||||
|
|
||||||
if current_version < 6:
|
|
||||||
logger.info("Migration v6: Per-Invoice Tracking statt per-Order")
|
|
||||||
try:
|
|
||||||
await db.execute("""
|
|
||||||
ALTER TABLE amazon_downloaded ADD COLUMN invoice_url TEXT DEFAULT ''
|
|
||||||
""")
|
|
||||||
except Exception:
|
|
||||||
pass # column already exists
|
|
||||||
await db.commit()
|
|
||||||
await _set_schema_version(db, 6)
|
|
||||||
|
|
||||||
if current_version < 8:
|
|
||||||
logger.info("Migration v7/8: Eingangs-/Ausgangsbelege Unterscheidung")
|
|
||||||
# Add beleg_type column to processing_log (check if it exists first)
|
|
||||||
cursor = await db.execute("PRAGMA table_info(processing_log)")
|
|
||||||
columns = [row[1] for row in await cursor.fetchall()]
|
|
||||||
if "beleg_type" not in columns:
|
|
||||||
await db.execute("""
|
|
||||||
ALTER TABLE processing_log ADD COLUMN beleg_type TEXT DEFAULT 'eingang'
|
|
||||||
""")
|
|
||||||
logger.info(" beleg_type Spalte hinzugefügt")
|
|
||||||
# Migrate import_email -> import_email_eingang
|
|
||||||
cursor = await db.execute(
|
|
||||||
"SELECT value FROM settings WHERE key = 'import_email'"
|
|
||||||
)
|
|
||||||
row = await cursor.fetchone()
|
|
||||||
if row and row[0]:
|
|
||||||
cursor2 = await db.execute(
|
|
||||||
"SELECT value FROM settings WHERE key = 'import_email_eingang'"
|
|
||||||
)
|
|
||||||
row2 = await cursor2.fetchone()
|
|
||||||
if not row2 or not row2[0]:
|
|
||||||
await db.execute(
|
|
||||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('import_email_eingang', ?)",
|
|
||||||
(row[0],),
|
|
||||||
)
|
|
||||||
logger.info(" import_email nach import_email_eingang übertragen")
|
|
||||||
await db.commit()
|
|
||||||
await _set_schema_version(db, 8)
|
|
||||||
|
|
||||||
if current_version < 9:
|
|
||||||
logger.info("Migration v9: FTP/SFTP-Settings hinzugefuegt (defaults werden eingefuegt)")
|
|
||||||
# No table changes needed - new settings are added via DEFAULT_SETTINGS loop in init_db
|
|
||||||
await db.commit()
|
|
||||||
await _set_schema_version(db, 9)
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def init_db():
|
async def init_db():
|
||||||
os.makedirs(os.path.dirname(DB_PATH) or ".", exist_ok=True)
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
# Ensure base tables exist (needed before we can read schema_version)
|
|
||||||
await db.execute("""
|
await db.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
@@ -280,34 +95,12 @@ async def init_db():
|
|||||||
email_from TEXT,
|
email_from TEXT,
|
||||||
attachments_count INTEGER DEFAULT 0,
|
attachments_count INTEGER DEFAULT 0,
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
error_message TEXT,
|
error_message TEXT
|
||||||
sent_to TEXT DEFAULT '',
|
|
||||||
smtp_log TEXT DEFAULT '',
|
|
||||||
beleg_type TEXT DEFAULT 'eingang'
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
await db.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS amazon_downloaded (
|
|
||||||
order_id TEXT NOT NULL,
|
|
||||||
downloaded_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
|
||||||
invoice_url TEXT DEFAULT '',
|
|
||||||
PRIMARY KEY (order_id, invoice_url)
|
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# Check version and run migrations
|
# Insert default settings if not present
|
||||||
current_version = await _get_schema_version(db)
|
|
||||||
if current_version < SCHEMA_VERSION:
|
|
||||||
logger.info(
|
|
||||||
f"DB-Schema v{current_version} -> v{SCHEMA_VERSION}, starte Migrationen..."
|
|
||||||
)
|
|
||||||
await _run_migrations(db, current_version)
|
|
||||||
logger.info(f"DB-Schema auf v{SCHEMA_VERSION} aktualisiert")
|
|
||||||
else:
|
|
||||||
logger.info(f"DB-Schema v{current_version} ist aktuell")
|
|
||||||
|
|
||||||
# Insert default settings for any new keys (never overwrites existing values)
|
|
||||||
for key, value in DEFAULT_SETTINGS.items():
|
for key, value in DEFAULT_SETTINGS.items():
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
|
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
|
||||||
@@ -323,7 +116,7 @@ async def get_settings() -> dict:
|
|||||||
fernet = await _get_fernet()
|
fernet = await _get_fernet()
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT key, value FROM settings WHERE key NOT IN ('encryption_key', 'schema_version')"
|
"SELECT key, value FROM settings WHERE key != 'encryption_key'"
|
||||||
)
|
)
|
||||||
rows = await cursor.fetchall()
|
rows = await cursor.fetchall()
|
||||||
|
|
||||||
@@ -340,7 +133,7 @@ async def save_settings(data: dict):
|
|||||||
fernet = await _get_fernet()
|
fernet = await _get_fernet()
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
for key, value in data.items():
|
for key, value in data.items():
|
||||||
if key in ("encryption_key", "schema_version"):
|
if key == "encryption_key":
|
||||||
continue
|
continue
|
||||||
store_value = _encrypt(fernet, value) if key in ENCRYPTED_KEYS else value
|
store_value = _encrypt(fernet, value) if key in ENCRYPTED_KEYS else value
|
||||||
await db.execute(
|
await db.execute(
|
||||||
@@ -350,29 +143,19 @@ async def save_settings(data: dict):
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
def get_import_email(settings: dict, beleg_type: str = "eingang") -> str:
|
|
||||||
"""Resolve the correct import email address based on document type."""
|
|
||||||
if beleg_type == "ausgang":
|
|
||||||
return settings.get("import_email_ausgang", "")
|
|
||||||
return settings.get("import_email_eingang", "") or settings.get("import_email", "")
|
|
||||||
|
|
||||||
|
|
||||||
async def add_log_entry(
|
async def add_log_entry(
|
||||||
email_subject: str,
|
email_subject: str,
|
||||||
email_from: str,
|
email_from: str,
|
||||||
attachments_count: int,
|
attachments_count: int,
|
||||||
status: str,
|
status: str,
|
||||||
error_message: str = "",
|
error_message: str = "",
|
||||||
sent_to: str = "",
|
|
||||||
smtp_log: str = "",
|
|
||||||
beleg_type: str = "eingang",
|
|
||||||
):
|
):
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""INSERT INTO processing_log
|
"""INSERT INTO processing_log
|
||||||
(email_subject, email_from, attachments_count, status, error_message, sent_to, smtp_log, beleg_type)
|
(email_subject, email_from, attachments_count, status, error_message)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?)""",
|
||||||
(email_subject, email_from, attachments_count, status, error_message, sent_to, smtp_log, beleg_type),
|
(email_subject, email_from, attachments_count, status, error_message),
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
@@ -385,46 +168,3 @@ async def get_log_entries(limit: int = 100) -> list[dict]:
|
|||||||
)
|
)
|
||||||
rows = await cursor.fetchall()
|
rows = await cursor.fetchall()
|
||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
async def clear_log_entries() -> int:
|
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
|
||||||
cursor = await db.execute("SELECT COUNT(*) FROM processing_log")
|
|
||||||
count = (await cursor.fetchone())[0]
|
|
||||||
await db.execute("DELETE FROM processing_log")
|
|
||||||
await db.commit()
|
|
||||||
return count
|
|
||||||
|
|
||||||
|
|
||||||
async def is_invoice_downloaded(order_id: str, invoice_url: str = "") -> bool:
|
|
||||||
"""Check if a specific invoice has been downloaded.
|
|
||||||
If invoice_url is given, check per-URL. Otherwise check per order_id."""
|
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
|
||||||
if invoice_url:
|
|
||||||
cursor = await db.execute(
|
|
||||||
"SELECT 1 FROM amazon_downloaded WHERE order_id = ? AND invoice_url = ?",
|
|
||||||
(order_id, invoice_url),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cursor = await db.execute(
|
|
||||||
"SELECT 1 FROM amazon_downloaded WHERE order_id = ?", (order_id,)
|
|
||||||
)
|
|
||||||
return await cursor.fetchone() is not None
|
|
||||||
|
|
||||||
|
|
||||||
async def mark_invoice_downloaded(order_id: str, invoice_url: str = ""):
|
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
|
||||||
await db.execute(
|
|
||||||
"INSERT OR IGNORE INTO amazon_downloaded (order_id, invoice_url) VALUES (?, ?)",
|
|
||||||
(order_id, invoice_url),
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def reset_downloaded_invoices() -> int:
|
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
|
||||||
cursor = await db.execute("SELECT COUNT(*) FROM amazon_downloaded")
|
|
||||||
count = (await cursor.fetchone())[0]
|
|
||||||
await db.execute("DELETE FROM amazon_downloaded")
|
|
||||||
await db.commit()
|
|
||||||
return count
|
|
||||||
|
|||||||
@@ -1,614 +0,0 @@
|
|||||||
"""FTP / SFTP file import processor.
|
|
||||||
|
|
||||||
Same design as smb_processor but for FTP (passive, unencrypted) and SFTP (SSH).
|
|
||||||
Reads PDF files from a remote source folder, forwards them via SMTP, then moves
|
|
||||||
them to a processed folder.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import ftplib
|
|
||||||
import io
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import posixpath
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
import paramiko
|
|
||||||
|
|
||||||
from app.database import get_settings, add_log_entry, get_import_email
|
|
||||||
from app.mail_processor import _connect_smtp, _build_forward_email, _send_with_log
|
|
||||||
from app.scanner import detect_separator_pages, split_pdf
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Generic adapter interface
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class _FtpAdapter:
|
|
||||||
"""Common interface for FTP and SFTP backends."""
|
|
||||||
|
|
||||||
def list_pdfs(self, path: str) -> list[str]:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def list_dirs(self, path: str, max_depth: int = 5) -> list[str]:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def read_file(self, path: str) -> bytes:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def ensure_dir(self, path: str):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def stat_exists(self, path: str) -> bool:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def rename(self, src: str, dst: str):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# FTP (passive, unencrypted)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class _PlainFtpAdapter(_FtpAdapter):
|
|
||||||
def __init__(self, server: str, port: int, username: str, password: str):
|
|
||||||
self.ftp = ftplib.FTP()
|
|
||||||
self.ftp.connect(server, port, timeout=15)
|
|
||||||
self.ftp.login(username or "anonymous", password or "")
|
|
||||||
self.ftp.set_pasv(True)
|
|
||||||
# Remember initial CWD - all subsequent operations should resolve relative to this
|
|
||||||
try:
|
|
||||||
self._initial_cwd = self.ftp.pwd()
|
|
||||||
except Exception:
|
|
||||||
self._initial_cwd = None
|
|
||||||
logger.debug(f"FTP initial CWD: {self._initial_cwd}")
|
|
||||||
|
|
||||||
def _reset_cwd(self):
|
|
||||||
"""Reset CWD to initial directory after stateful operations."""
|
|
||||||
if self._initial_cwd:
|
|
||||||
try:
|
|
||||||
self.ftp.cwd(self._initial_cwd)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def list_pdfs(self, path: str) -> list[str]:
|
|
||||||
"""List PDF files via LIST (more reliable than NLST across FTP servers)."""
|
|
||||||
self._reset_cwd()
|
|
||||||
target = path if path else "."
|
|
||||||
lines = []
|
|
||||||
try:
|
|
||||||
self.ftp.retrlines(f"LIST {target}", lines.append)
|
|
||||||
except ftplib.error_perm:
|
|
||||||
return []
|
|
||||||
files = []
|
|
||||||
for line in lines:
|
|
||||||
if not line or line[0:1] == "d":
|
|
||||||
continue # skip directories
|
|
||||||
parts = line.split(maxsplit=8)
|
|
||||||
if len(parts) < 9:
|
|
||||||
continue
|
|
||||||
name = parts[-1]
|
|
||||||
if name.lower().endswith(".pdf") and not name.startswith("."):
|
|
||||||
files.append(name)
|
|
||||||
return sorted(files)
|
|
||||||
|
|
||||||
def list_dirs(self, path: str, max_depth: int = 5) -> list[str]:
|
|
||||||
self._reset_cwd()
|
|
||||||
base = path or ""
|
|
||||||
logger.debug(f"FTP list_dirs: base={base!r}, max_depth={max_depth}")
|
|
||||||
return self._list_dirs_rec(base, max_depth, 0, "")
|
|
||||||
|
|
||||||
def _list_dirs_rec(self, base: str, max_depth: int, depth: int, prefix: str) -> list[str]:
|
|
||||||
result = []
|
|
||||||
try:
|
|
||||||
entries = []
|
|
||||||
target = base if base else "."
|
|
||||||
self.ftp.retrlines(f"LIST {target}", entries.append)
|
|
||||||
logger.debug(f"FTP LIST {target!r} -> {len(entries)} entries")
|
|
||||||
except ftplib.error_perm as e:
|
|
||||||
logger.warning(f"FTP LIST {base!r} failed: {e}")
|
|
||||||
return []
|
|
||||||
for line in entries:
|
|
||||||
# Try to detect dirs (line starts with 'd') - works for unix-style listings
|
|
||||||
if not line or not line[0:1] == "d":
|
|
||||||
continue
|
|
||||||
parts = line.split(maxsplit=8)
|
|
||||||
if len(parts) < 9:
|
|
||||||
continue
|
|
||||||
name = parts[-1]
|
|
||||||
if name in (".", "..") or name.startswith("."):
|
|
||||||
continue
|
|
||||||
rel = f"{prefix}/{name}" if prefix else name
|
|
||||||
result.append(rel)
|
|
||||||
if depth < max_depth - 1:
|
|
||||||
sub = posixpath.join(base, name) if base else name
|
|
||||||
result.extend(self._list_dirs_rec(sub, max_depth, depth + 1, rel))
|
|
||||||
return result
|
|
||||||
|
|
||||||
def read_file(self, path: str) -> bytes:
|
|
||||||
self._reset_cwd()
|
|
||||||
buf = io.BytesIO()
|
|
||||||
self.ftp.retrbinary(f"RETR {path}", buf.write)
|
|
||||||
return buf.getvalue()
|
|
||||||
|
|
||||||
def ensure_dir(self, path: str):
|
|
||||||
"""Create directory tree, walking step by step from initial CWD.
|
|
||||||
|
|
||||||
IMPORTANT: FTP's cwd() is stateful - it changes the current working
|
|
||||||
directory for ALL subsequent operations. We must walk the path one
|
|
||||||
segment at a time relative to the current position, not concatenate
|
|
||||||
and re-cwd from initial each iteration.
|
|
||||||
"""
|
|
||||||
if not path:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
self._reset_cwd()
|
|
||||||
parts = [p for p in path.split("/") if p]
|
|
||||||
for p in parts:
|
|
||||||
try:
|
|
||||||
self.ftp.cwd(p)
|
|
||||||
except ftplib.error_perm:
|
|
||||||
# Doesn't exist - create and enter
|
|
||||||
try:
|
|
||||||
self.ftp.mkd(p)
|
|
||||||
self.ftp.cwd(p)
|
|
||||||
except ftplib.error_perm as e:
|
|
||||||
logger.warning(f"FTP mkd({p}) failed: {e}")
|
|
||||||
return
|
|
||||||
finally:
|
|
||||||
self._reset_cwd()
|
|
||||||
|
|
||||||
def stat_exists(self, path: str) -> bool:
|
|
||||||
"""Check if a file or directory exists at path."""
|
|
||||||
try:
|
|
||||||
self.ftp.size(path)
|
|
||||||
return True
|
|
||||||
except (ftplib.error_perm, ftplib.error_temp):
|
|
||||||
pass
|
|
||||||
# Try as directory - cwd then immediately reset
|
|
||||||
try:
|
|
||||||
self.ftp.cwd(path)
|
|
||||||
self._reset_cwd()
|
|
||||||
return True
|
|
||||||
except ftplib.error_perm:
|
|
||||||
self._reset_cwd()
|
|
||||||
return False
|
|
||||||
|
|
||||||
def rename(self, src: str, dst: str):
|
|
||||||
self._reset_cwd()
|
|
||||||
self.ftp.rename(src, dst)
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
try:
|
|
||||||
self.ftp.quit()
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
self.ftp.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# SFTP (paramiko)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class _SftpAdapter(_FtpAdapter):
|
|
||||||
def __init__(self, server: str, port: int, username: str, password: str):
|
|
||||||
self.transport = paramiko.Transport((server, port))
|
|
||||||
self.transport.connect(username=username, password=password)
|
|
||||||
self.sftp = paramiko.SFTPClient.from_transport(self.transport)
|
|
||||||
|
|
||||||
def _resolve(self, path: str) -> str:
|
|
||||||
"""Resolve path - empty/None means user's home/root directory."""
|
|
||||||
if not path:
|
|
||||||
try:
|
|
||||||
return self.sftp.normalize(".")
|
|
||||||
except IOError:
|
|
||||||
return "."
|
|
||||||
return path
|
|
||||||
|
|
||||||
def list_pdfs(self, path: str) -> list[str]:
|
|
||||||
try:
|
|
||||||
entries = self.sftp.listdir(self._resolve(path))
|
|
||||||
except IOError:
|
|
||||||
return []
|
|
||||||
return sorted(
|
|
||||||
e for e in entries if e.lower().endswith(".pdf") and not e.startswith(".")
|
|
||||||
)
|
|
||||||
|
|
||||||
def list_dirs(self, path: str, max_depth: int = 5) -> list[str]:
|
|
||||||
base = self._resolve(path)
|
|
||||||
logger.debug(f"SFTP list_dirs: base={base!r}, max_depth={max_depth}")
|
|
||||||
return self._list_dirs_rec(base, max_depth, 0, "")
|
|
||||||
|
|
||||||
def _list_dirs_rec(self, base: str, max_depth: int, depth: int, prefix: str) -> list[str]:
|
|
||||||
from stat import S_ISDIR
|
|
||||||
result = []
|
|
||||||
try:
|
|
||||||
entries = self.sftp.listdir_attr(base)
|
|
||||||
logger.debug(f"SFTP listdir_attr({base!r}) -> {[e.filename for e in entries]}")
|
|
||||||
except IOError as e:
|
|
||||||
logger.warning(f"SFTP listdir_attr({base!r}) failed: {e}")
|
|
||||||
return result
|
|
||||||
for entry in entries:
|
|
||||||
if entry.filename.startswith(".") or entry.filename in ("..", "."):
|
|
||||||
continue
|
|
||||||
if entry.st_mode and S_ISDIR(entry.st_mode):
|
|
||||||
rel = f"{prefix}/{entry.filename}" if prefix else entry.filename
|
|
||||||
result.append(rel)
|
|
||||||
if depth < max_depth - 1:
|
|
||||||
sub = posixpath.join(base, entry.filename)
|
|
||||||
result.extend(self._list_dirs_rec(sub, max_depth, depth + 1, rel))
|
|
||||||
return result
|
|
||||||
|
|
||||||
def read_file(self, path: str) -> bytes:
|
|
||||||
with self.sftp.open(path, "rb") as f:
|
|
||||||
return f.read()
|
|
||||||
|
|
||||||
def ensure_dir(self, path: str):
|
|
||||||
if not path:
|
|
||||||
return
|
|
||||||
parts = [p for p in path.split("/") if p]
|
|
||||||
cur = ""
|
|
||||||
for p in parts:
|
|
||||||
cur = f"{cur}/{p}" if cur else p
|
|
||||||
try:
|
|
||||||
self.sftp.stat(cur)
|
|
||||||
except IOError:
|
|
||||||
try:
|
|
||||||
self.sftp.mkdir(cur)
|
|
||||||
except IOError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def stat_exists(self, path: str) -> bool:
|
|
||||||
try:
|
|
||||||
self.sftp.stat(path)
|
|
||||||
return True
|
|
||||||
except IOError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def rename(self, src: str, dst: str):
|
|
||||||
self.sftp.rename(src, dst)
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
try:
|
|
||||||
self.sftp.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
self.transport.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _make_adapter(settings: dict) -> _FtpAdapter:
|
|
||||||
protocol = settings.get("ftp_protocol", "sftp").lower()
|
|
||||||
server = settings["ftp_server"]
|
|
||||||
username = settings.get("ftp_username", "")
|
|
||||||
password = settings.get("ftp_password", "")
|
|
||||||
if protocol == "sftp":
|
|
||||||
port = int(settings.get("ftp_port") or 22)
|
|
||||||
return _SftpAdapter(server, port, username, password)
|
|
||||||
else:
|
|
||||||
port = int(settings.get("ftp_port") or 21)
|
|
||||||
return _PlainFtpAdapter(server, port, username, password)
|
|
||||||
|
|
||||||
|
|
||||||
def _join_path(*parts: str) -> str:
|
|
||||||
"""Join FTP/SFTP path segments using forward slash."""
|
|
||||||
result = ""
|
|
||||||
for p in parts:
|
|
||||||
if not p:
|
|
||||||
continue
|
|
||||||
p = p.replace("\\", "/").strip("/")
|
|
||||||
if not p:
|
|
||||||
continue
|
|
||||||
result = f"{result}/{p}" if result else p
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _move_with_dedup(adapter: _FtpAdapter, src: str, dest_dir: str, filename: str):
|
|
||||||
"""Move file to dest_dir, renaming if a duplicate exists."""
|
|
||||||
dest = _join_path(dest_dir, filename)
|
|
||||||
if adapter.stat_exists(dest):
|
|
||||||
name, ext = os.path.splitext(filename)
|
|
||||||
counter = 1
|
|
||||||
while True:
|
|
||||||
new_name = f"{name}_{counter}{ext}"
|
|
||||||
new_dest = _join_path(dest_dir, new_name)
|
|
||||||
if not adapter.stat_exists(new_dest):
|
|
||||||
dest = new_dest
|
|
||||||
break
|
|
||||||
counter += 1
|
|
||||||
adapter.rename(src, dest)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Processing pipeline
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _process_ftp_folder(
|
|
||||||
smtp_conn, settings: dict, adapter: _FtpAdapter,
|
|
||||||
source_path: str, processed_path: str,
|
|
||||||
import_email: str, beleg_type: str, mode: str,
|
|
||||||
) -> dict:
|
|
||||||
"""Process one FTP folder pair. Returns counts dict."""
|
|
||||||
smtp_from = settings.get("smtp_username", "")
|
|
||||||
protocol = settings.get("ftp_protocol", "sftp").upper()
|
|
||||||
processed = 0
|
|
||||||
skipped = 0
|
|
||||||
errors = 0
|
|
||||||
|
|
||||||
await asyncio.to_thread(adapter.ensure_dir, processed_path)
|
|
||||||
|
|
||||||
pdf_files = await asyncio.to_thread(adapter.list_pdfs, source_path)
|
|
||||||
if not pdf_files:
|
|
||||||
logger.info(f"Keine PDF-Dateien im {protocol}-Ordner '{source_path}' ({beleg_type})")
|
|
||||||
return {"processed": 0, "skipped": 0, "errors": 0}
|
|
||||||
|
|
||||||
logger.info(f"{len(pdf_files)} PDF-Datei(en) im {protocol}-Ordner '{source_path}' ({beleg_type})")
|
|
||||||
|
|
||||||
for filename in pdf_files:
|
|
||||||
file_path = _join_path(source_path, filename)
|
|
||||||
try:
|
|
||||||
pdf_data = await asyncio.to_thread(adapter.read_file, file_path)
|
|
||||||
|
|
||||||
if mode == "separator":
|
|
||||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
|
|
||||||
tmp.write(pdf_data)
|
|
||||||
tmp_path = tmp.name
|
|
||||||
try:
|
|
||||||
separator_pages = await asyncio.to_thread(detect_separator_pages, tmp_path, None)
|
|
||||||
documents = await asyncio.to_thread(split_pdf, tmp_path, separator_pages)
|
|
||||||
finally:
|
|
||||||
os.unlink(tmp_path)
|
|
||||||
|
|
||||||
if not documents:
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
smtp_log_parts = []
|
|
||||||
for i, doc_bytes in enumerate(documents):
|
|
||||||
doc_filename = f"{os.path.splitext(filename)[0]}_Teil_{i + 1}.pdf"
|
|
||||||
subject = f"{protocol}-Import: {filename} (Dokument {i + 1}/{len(documents)})"
|
|
||||||
msg = _build_forward_email(
|
|
||||||
from_addr=smtp_from,
|
|
||||||
to_addr=import_email,
|
|
||||||
original_subject=subject,
|
|
||||||
original_from=f"{protocol}-Import",
|
|
||||||
attachments=[(doc_filename, doc_bytes)],
|
|
||||||
)
|
|
||||||
smtp_log_parts.append(_send_with_log(smtp_conn, msg))
|
|
||||||
|
|
||||||
await add_log_entry(
|
|
||||||
email_subject=f"{protocol}: {filename}",
|
|
||||||
email_from=f"{protocol}-Import",
|
|
||||||
attachments_count=len(documents),
|
|
||||||
status="success",
|
|
||||||
sent_to=import_email,
|
|
||||||
smtp_log="\n---\n".join(smtp_log_parts),
|
|
||||||
beleg_type=beleg_type,
|
|
||||||
)
|
|
||||||
logger.info(f"{protocol} verarbeitet ({beleg_type}): {filename} -> {len(documents)} Dokument(e)")
|
|
||||||
else:
|
|
||||||
msg = _build_forward_email(
|
|
||||||
from_addr=smtp_from,
|
|
||||||
to_addr=import_email,
|
|
||||||
original_subject=f"{protocol}-Import: {filename}",
|
|
||||||
original_from=f"{protocol}-Import",
|
|
||||||
attachments=[(filename, pdf_data)],
|
|
||||||
)
|
|
||||||
smtp_log = _send_with_log(smtp_conn, msg)
|
|
||||||
|
|
||||||
await add_log_entry(
|
|
||||||
email_subject=f"{protocol}: {filename}",
|
|
||||||
email_from=f"{protocol}-Import",
|
|
||||||
attachments_count=1,
|
|
||||||
status="success",
|
|
||||||
sent_to=import_email,
|
|
||||||
smtp_log=smtp_log,
|
|
||||||
beleg_type=beleg_type,
|
|
||||||
)
|
|
||||||
logger.info(f"{protocol} verarbeitet ({beleg_type}): {filename}")
|
|
||||||
|
|
||||||
await asyncio.to_thread(_move_with_dedup, adapter, file_path, processed_path, filename)
|
|
||||||
processed += 1
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
errors += 1
|
|
||||||
logger.error(f"Fehler bei {protocol}-Datei {filename}: {e}")
|
|
||||||
try:
|
|
||||||
await add_log_entry(
|
|
||||||
email_subject=f"{protocol}: {filename}",
|
|
||||||
email_from=f"{protocol}-Import",
|
|
||||||
attachments_count=0,
|
|
||||||
status="error",
|
|
||||||
error_message=str(e),
|
|
||||||
beleg_type=beleg_type,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {"processed": processed, "skipped": skipped, "errors": errors}
|
|
||||||
|
|
||||||
|
|
||||||
async def process_ftp() -> dict:
|
|
||||||
"""Process PDF files from FTP/SFTP server - main pipeline."""
|
|
||||||
settings = await get_settings()
|
|
||||||
|
|
||||||
if settings.get("ftp_enabled") != "true":
|
|
||||||
return {"processed": 0, "skipped": 0, "errors": 0}
|
|
||||||
|
|
||||||
if not settings.get("ftp_server"):
|
|
||||||
return {"processed": 0, "skipped": 0, "errors": 0, "error": "FTP nicht konfiguriert"}
|
|
||||||
|
|
||||||
import_email_eingang = get_import_email(settings, "eingang")
|
|
||||||
if not import_email_eingang:
|
|
||||||
return {"processed": 0, "skipped": 0, "errors": 0, "error": "Import-Email nicht konfiguriert"}
|
|
||||||
|
|
||||||
mode = settings.get("ftp_mode", "forward")
|
|
||||||
protocol = settings.get("ftp_protocol", "sftp").upper()
|
|
||||||
total = {"processed": 0, "skipped": 0, "errors": 0}
|
|
||||||
smtp_conn = None
|
|
||||||
adapter = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
adapter = await asyncio.to_thread(_make_adapter, settings)
|
|
||||||
smtp_conn = _connect_smtp(settings)
|
|
||||||
|
|
||||||
# Eingangsbelege
|
|
||||||
source = settings.get("ftp_source_path", "")
|
|
||||||
processed_path = settings.get("ftp_processed_path", "Verarbeitet")
|
|
||||||
result = await _process_ftp_folder(
|
|
||||||
smtp_conn, settings, adapter,
|
|
||||||
source, processed_path,
|
|
||||||
import_email_eingang, "eingang", mode,
|
|
||||||
)
|
|
||||||
for k in total:
|
|
||||||
total[k] += result[k]
|
|
||||||
|
|
||||||
# Ausgangsbelege (optional)
|
|
||||||
import_email_ausgang = get_import_email(settings, "ausgang")
|
|
||||||
source_ausgang = settings.get("ftp_source_path_ausgang", "")
|
|
||||||
processed_ausgang = settings.get("ftp_processed_path_ausgang", "")
|
|
||||||
if import_email_ausgang and source_ausgang:
|
|
||||||
if not processed_ausgang:
|
|
||||||
processed_ausgang = source_ausgang + "/Verarbeitet"
|
|
||||||
result = await _process_ftp_folder(
|
|
||||||
smtp_conn, settings, adapter,
|
|
||||||
source_ausgang, processed_ausgang,
|
|
||||||
import_email_ausgang, "ausgang", mode,
|
|
||||||
)
|
|
||||||
for k in total:
|
|
||||||
total[k] += result[k]
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"{protocol}-Verbindungsfehler: {e}")
|
|
||||||
try:
|
|
||||||
await add_log_entry(
|
|
||||||
email_subject="",
|
|
||||||
email_from=f"{protocol}-Import",
|
|
||||||
attachments_count=0,
|
|
||||||
status="error",
|
|
||||||
error_message=f"{protocol}-Verbindungsfehler: {e}",
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {**total, "errors": total["errors"] + 1, "error": str(e)}
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if adapter:
|
|
||||||
try:
|
|
||||||
await asyncio.to_thread(adapter.close)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if smtp_conn:
|
|
||||||
try:
|
|
||||||
smtp_conn.quit()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
logger.info(f"{protocol} fertig: {total['processed']} verarbeitet, {total['skipped']} übersprungen, {total['errors']} Fehler")
|
|
||||||
return total
|
|
||||||
|
|
||||||
|
|
||||||
async def test_ftp_connection() -> dict:
|
|
||||||
"""Test FTP/SFTP connection and return TOP-LEVEL folders only (lazy loading)."""
|
|
||||||
settings = await get_settings()
|
|
||||||
if not settings.get("ftp_server"):
|
|
||||||
return {"success": False, "error": "FTP-Server nicht konfiguriert", "folders": []}
|
|
||||||
|
|
||||||
adapter = None
|
|
||||||
try:
|
|
||||||
adapter = await asyncio.to_thread(_make_adapter, settings)
|
|
||||||
folders = await asyncio.to_thread(adapter.list_dirs, "", 1)
|
|
||||||
return {"success": True, "folders": sorted(folders)}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"FTP-Test fehlgeschlagen: {e}")
|
|
||||||
return {"success": False, "error": str(e), "folders": []}
|
|
||||||
finally:
|
|
||||||
if adapter:
|
|
||||||
try:
|
|
||||||
await asyncio.to_thread(adapter.close)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
async def create_ftp_folder(folder_path: str) -> dict:
|
|
||||||
"""Create a folder on the FTP/SFTP server."""
|
|
||||||
settings = await get_settings()
|
|
||||||
if not settings.get("ftp_server"):
|
|
||||||
return {"success": False, "error": "FTP nicht konfiguriert"}
|
|
||||||
if not folder_path or not folder_path.strip():
|
|
||||||
return {"success": False, "error": "Ordnername darf nicht leer sein"}
|
|
||||||
|
|
||||||
folder_path = folder_path.strip().replace("\\", "/")
|
|
||||||
adapter = None
|
|
||||||
try:
|
|
||||||
adapter = await asyncio.to_thread(_make_adapter, settings)
|
|
||||||
await asyncio.to_thread(adapter.ensure_dir, folder_path)
|
|
||||||
return {"success": True}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"FTP-Ordner erstellen fehlgeschlagen: {e}")
|
|
||||||
return {"success": False, "error": str(e)}
|
|
||||||
finally:
|
|
||||||
if adapter:
|
|
||||||
try:
|
|
||||||
await asyncio.to_thread(adapter.close)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
async def list_ftp_folders() -> dict:
|
|
||||||
"""Return TOP-LEVEL folder list from FTP/SFTP server (lazy loading)."""
|
|
||||||
settings = await get_settings()
|
|
||||||
if not settings.get("ftp_server"):
|
|
||||||
return {"folders": []}
|
|
||||||
adapter = None
|
|
||||||
try:
|
|
||||||
adapter = await asyncio.to_thread(_make_adapter, settings)
|
|
||||||
folders = await asyncio.to_thread(adapter.list_dirs, "", 1)
|
|
||||||
return {"folders": sorted(folders)}
|
|
||||||
except Exception:
|
|
||||||
return {"folders": []}
|
|
||||||
finally:
|
|
||||||
if adapter:
|
|
||||||
try:
|
|
||||||
await asyncio.to_thread(adapter.close)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
async def list_ftp_subfolders(parent_path: str) -> dict:
|
|
||||||
"""List direct subfolders of a path (one level deep, for lazy tree expansion)."""
|
|
||||||
settings = await get_settings()
|
|
||||||
if not settings.get("ftp_server"):
|
|
||||||
return {"success": False, "error": "FTP nicht konfiguriert", "folders": []}
|
|
||||||
adapter = None
|
|
||||||
try:
|
|
||||||
adapter = await asyncio.to_thread(_make_adapter, settings)
|
|
||||||
rel_folders = await asyncio.to_thread(adapter.list_dirs, parent_path, 1)
|
|
||||||
# Prefix with parent_path so the frontend has full paths
|
|
||||||
if parent_path:
|
|
||||||
folders = [f"{parent_path}/{f}" for f in rel_folders]
|
|
||||||
else:
|
|
||||||
folders = rel_folders
|
|
||||||
return {"success": True, "folders": sorted(folders)}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"FTP-Subfolder-Liste fehlgeschlagen: {e}")
|
|
||||||
return {"success": False, "error": str(e), "folders": []}
|
|
||||||
finally:
|
|
||||||
if adapter:
|
|
||||||
try:
|
|
||||||
await asyncio.to_thread(adapter.close)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
+113
-183
@@ -9,30 +9,11 @@ from email.mime.text import MIMEText
|
|||||||
from email import encoders
|
from email import encoders
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from app.database import get_settings, add_log_entry, get_import_email
|
from app.database import get_settings, add_log_entry
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _send_with_log(smtp_conn: smtplib.SMTP, msg) -> str:
|
|
||||||
"""Send email and capture SMTP protocol exchange."""
|
|
||||||
log_lines = []
|
|
||||||
original_print_debug = smtp_conn._print_debug
|
|
||||||
|
|
||||||
def _capture(*args):
|
|
||||||
log_lines.append(" ".join(str(a) for a in args))
|
|
||||||
|
|
||||||
smtp_conn._print_debug = _capture
|
|
||||||
old_level = smtp_conn.debuglevel
|
|
||||||
smtp_conn.set_debuglevel(1)
|
|
||||||
try:
|
|
||||||
smtp_conn.send_message(msg)
|
|
||||||
finally:
|
|
||||||
smtp_conn.set_debuglevel(old_level)
|
|
||||||
smtp_conn._print_debug = original_print_debug
|
|
||||||
return "\n".join(log_lines)
|
|
||||||
|
|
||||||
|
|
||||||
def _connect_imap(settings: dict) -> imaplib.IMAP4_SSL | imaplib.IMAP4:
|
def _connect_imap(settings: dict) -> imaplib.IMAP4_SSL | imaplib.IMAP4:
|
||||||
server = settings["imap_server"]
|
server = settings["imap_server"]
|
||||||
port = int(settings.get("imap_port", 993))
|
port = int(settings.get("imap_port", 993))
|
||||||
@@ -104,7 +85,7 @@ def _build_forward_email(
|
|||||||
msg["Subject"] = f"Belegimport: {original_subject}"
|
msg["Subject"] = f"Belegimport: {original_subject}"
|
||||||
|
|
||||||
body = (
|
body = (
|
||||||
f"Automatisch weitergeleitet von Belegimport.\n"
|
f"Automatisch weitergeleitet von LexOffice Belegimport.\n"
|
||||||
f"Original-Absender: {original_from}\n"
|
f"Original-Absender: {original_from}\n"
|
||||||
f"Original-Betreff: {original_subject}\n"
|
f"Original-Betreff: {original_subject}\n"
|
||||||
f"Anzahl Anhänge: {len(attachments)}"
|
f"Anzahl Anhänge: {len(attachments)}"
|
||||||
@@ -137,117 +118,21 @@ def _move_email(conn: imaplib.IMAP4, msg_uid: bytes, dest_folder: str):
|
|||||||
conn.expunge()
|
conn.expunge()
|
||||||
|
|
||||||
|
|
||||||
async def _process_folder(
|
|
||||||
imap_conn, smtp_conn, settings: dict,
|
|
||||||
source_folder: str, processed_folder: str,
|
|
||||||
import_email: str, beleg_type: str, fetch_since: str,
|
|
||||||
) -> dict:
|
|
||||||
"""Process one IMAP folder pair. Returns counts dict."""
|
|
||||||
smtp_from = settings.get("smtp_username", "")
|
|
||||||
processed = 0
|
|
||||||
skipped = 0
|
|
||||||
errors = 0
|
|
||||||
|
|
||||||
_ensure_folder_exists(imap_conn, processed_folder)
|
|
||||||
|
|
||||||
status, _ = imap_conn.select(f'"{source_folder}"')
|
|
||||||
if status != "OK":
|
|
||||||
logger.warning(f"Ordner '{source_folder}' konnte nicht geöffnet werden")
|
|
||||||
return {"processed": 0, "skipped": 0, "errors": 0}
|
|
||||||
|
|
||||||
search_criteria = "ALL"
|
|
||||||
if fetch_since:
|
|
||||||
try:
|
|
||||||
from datetime import datetime
|
|
||||||
dt = datetime.strptime(fetch_since, "%Y-%m-%d")
|
|
||||||
imap_date = dt.strftime("%d-%b-%Y")
|
|
||||||
search_criteria = f'(SINCE {imap_date})'
|
|
||||||
except ValueError:
|
|
||||||
logger.warning(f"Ungültiges Datum: {fetch_since}, verwende ALL")
|
|
||||||
|
|
||||||
status, data = imap_conn.uid("SEARCH", None, search_criteria)
|
|
||||||
if status != "OK" or not data[0]:
|
|
||||||
logger.info(f"Keine Emails im Ordner '{source_folder}' ({beleg_type})")
|
|
||||||
return {"processed": 0, "skipped": 0, "errors": 0}
|
|
||||||
|
|
||||||
msg_uids = data[0].split()
|
|
||||||
logger.info(f"{len(msg_uids)} Email(s) im Ordner '{source_folder}' ({beleg_type})")
|
|
||||||
|
|
||||||
for msg_uid in msg_uids:
|
|
||||||
subject = "?"
|
|
||||||
from_addr = "?"
|
|
||||||
try:
|
|
||||||
status, msg_data = imap_conn.uid("FETCH", msg_uid, "(RFC822)")
|
|
||||||
if status != "OK":
|
|
||||||
continue
|
|
||||||
|
|
||||||
raw_email = msg_data[0][1]
|
|
||||||
msg = email.message_from_bytes(raw_email, policy=policy.default)
|
|
||||||
|
|
||||||
subject = str(msg.get("Subject", "(Kein Betreff)"))
|
|
||||||
from_addr = str(msg.get("From", "(Unbekannt)"))
|
|
||||||
|
|
||||||
attachments = _extract_attachments(msg)
|
|
||||||
|
|
||||||
if not attachments:
|
|
||||||
skipped += 1
|
|
||||||
logger.debug(f"Übersprungen (keine Anhänge): {subject}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
forward_msg = _build_forward_email(
|
|
||||||
from_addr=smtp_from,
|
|
||||||
to_addr=import_email,
|
|
||||||
original_subject=subject,
|
|
||||||
original_from=from_addr,
|
|
||||||
attachments=attachments,
|
|
||||||
)
|
|
||||||
|
|
||||||
smtp_log = _send_with_log(smtp_conn, forward_msg)
|
|
||||||
|
|
||||||
imap_conn.select(f'"{source_folder}"')
|
|
||||||
_move_email(imap_conn, msg_uid, processed_folder)
|
|
||||||
imap_conn.select(f'"{source_folder}"')
|
|
||||||
|
|
||||||
processed += 1
|
|
||||||
logger.info(f"Verarbeitet ({beleg_type}): {subject} ({len(attachments)} Anhänge)")
|
|
||||||
await add_log_entry(
|
|
||||||
email_subject=subject,
|
|
||||||
email_from=from_addr,
|
|
||||||
attachments_count=len(attachments),
|
|
||||||
status="success",
|
|
||||||
sent_to=import_email,
|
|
||||||
smtp_log=smtp_log,
|
|
||||||
beleg_type=beleg_type,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
errors += 1
|
|
||||||
logger.error(f"Fehler bei Email UID {msg_uid}: {e}")
|
|
||||||
try:
|
|
||||||
await add_log_entry(
|
|
||||||
email_subject=subject,
|
|
||||||
email_from=from_addr,
|
|
||||||
attachments_count=0,
|
|
||||||
status="error",
|
|
||||||
error_message=str(e),
|
|
||||||
beleg_type=beleg_type,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {"processed": processed, "skipped": skipped, "errors": errors}
|
|
||||||
|
|
||||||
|
|
||||||
async def process_mailbox() -> dict:
|
async def process_mailbox() -> dict:
|
||||||
settings = await get_settings()
|
settings = await get_settings()
|
||||||
|
|
||||||
import_email_eingang = get_import_email(settings, "eingang")
|
if not settings.get("imap_server") or not settings.get("lexoffice_email"):
|
||||||
if not settings.get("imap_server") or not import_email_eingang:
|
logger.warning("IMAP oder LexOffice-Email nicht konfiguriert")
|
||||||
logger.warning("IMAP oder Import-Email nicht konfiguriert")
|
|
||||||
return {"processed": 0, "skipped": 0, "errors": 0, "error": "Nicht konfiguriert"}
|
return {"processed": 0, "skipped": 0, "errors": 0, "error": "Nicht konfiguriert"}
|
||||||
|
|
||||||
fetch_since = settings.get("fetch_since_date", "")
|
source_folder = settings.get("source_folder", "INBOX")
|
||||||
total = {"processed": 0, "skipped": 0, "errors": 0}
|
processed_folder = settings.get("processed_folder", "INBOX/Verarbeitet")
|
||||||
|
lexoffice_email = settings["lexoffice_email"]
|
||||||
|
smtp_from = settings.get("smtp_username", "")
|
||||||
|
|
||||||
|
processed = 0
|
||||||
|
skipped = 0
|
||||||
|
errors = 0
|
||||||
imap_conn = None
|
imap_conn = None
|
||||||
smtp_conn = None
|
smtp_conn = None
|
||||||
|
|
||||||
@@ -255,31 +140,92 @@ async def process_mailbox() -> dict:
|
|||||||
imap_conn = _connect_imap(settings)
|
imap_conn = _connect_imap(settings)
|
||||||
smtp_conn = _connect_smtp(settings)
|
smtp_conn = _connect_smtp(settings)
|
||||||
|
|
||||||
# Eingangsbelege
|
_ensure_folder_exists(imap_conn, processed_folder)
|
||||||
source = settings.get("source_folder", "INBOX")
|
|
||||||
processed_folder = settings.get("processed_folder", "INBOX/Verarbeitet")
|
|
||||||
result = await _process_folder(
|
|
||||||
imap_conn, smtp_conn, settings,
|
|
||||||
source, processed_folder,
|
|
||||||
import_email_eingang, "eingang", fetch_since,
|
|
||||||
)
|
|
||||||
for k in total:
|
|
||||||
total[k] += result[k]
|
|
||||||
|
|
||||||
# Ausgangsbelege (optional)
|
status, _ = imap_conn.select(f'"{source_folder}"')
|
||||||
import_email_ausgang = get_import_email(settings, "ausgang")
|
if status != "OK":
|
||||||
source_ausgang = settings.get("source_folder_ausgang", "")
|
raise Exception(f"Ordner '{source_folder}' konnte nicht geöffnet werden")
|
||||||
processed_ausgang = settings.get("processed_folder_ausgang", "")
|
|
||||||
if import_email_ausgang and source_ausgang:
|
# Build IMAP search criteria
|
||||||
if not processed_ausgang:
|
search_criteria = "ALL"
|
||||||
processed_ausgang = source_ausgang + "/Verarbeitet"
|
fetch_since = settings.get("fetch_since_date", "")
|
||||||
result = await _process_folder(
|
if fetch_since:
|
||||||
imap_conn, smtp_conn, settings,
|
try:
|
||||||
source_ausgang, processed_ausgang,
|
from datetime import datetime
|
||||||
import_email_ausgang, "ausgang", fetch_since,
|
dt = datetime.strptime(fetch_since, "%Y-%m-%d")
|
||||||
)
|
imap_date = dt.strftime("%d-%b-%Y")
|
||||||
for k in total:
|
search_criteria = f'(SINCE {imap_date})'
|
||||||
total[k] += result[k]
|
except ValueError:
|
||||||
|
logger.warning(f"Ungültiges Datum: {fetch_since}, verwende ALL")
|
||||||
|
|
||||||
|
status, data = imap_conn.uid("SEARCH", None, search_criteria)
|
||||||
|
if status != "OK" or not data[0]:
|
||||||
|
logger.info("Keine Emails im Ordner gefunden")
|
||||||
|
return {"processed": 0, "skipped": 0, "errors": 0}
|
||||||
|
|
||||||
|
msg_uids = data[0].split()
|
||||||
|
logger.info(f"{len(msg_uids)} Email(s) im Ordner '{source_folder}' gefunden")
|
||||||
|
|
||||||
|
for msg_uid in msg_uids:
|
||||||
|
try:
|
||||||
|
status, msg_data = imap_conn.uid("FETCH", msg_uid, "(RFC822)")
|
||||||
|
if status != "OK":
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw_email = msg_data[0][1]
|
||||||
|
msg = email.message_from_bytes(raw_email, policy=policy.default)
|
||||||
|
|
||||||
|
subject = str(msg.get("Subject", "(Kein Betreff)"))
|
||||||
|
from_addr = str(msg.get("From", "(Unbekannt)"))
|
||||||
|
|
||||||
|
attachments = _extract_attachments(msg)
|
||||||
|
|
||||||
|
if not attachments:
|
||||||
|
skipped += 1
|
||||||
|
logger.debug(f"Übersprungen (keine Anhänge): {subject}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
forward_msg = _build_forward_email(
|
||||||
|
from_addr=smtp_from,
|
||||||
|
to_addr=lexoffice_email,
|
||||||
|
original_subject=subject,
|
||||||
|
original_from=from_addr,
|
||||||
|
attachments=attachments,
|
||||||
|
)
|
||||||
|
|
||||||
|
smtp_conn.send_message(forward_msg)
|
||||||
|
|
||||||
|
# Re-select source folder before move (in case _ensure_folder changed it)
|
||||||
|
imap_conn.select(f'"{source_folder}"')
|
||||||
|
_move_email(imap_conn, msg_uid, processed_folder)
|
||||||
|
|
||||||
|
# Re-select after expunge to keep UIDs valid
|
||||||
|
imap_conn.select(f'"{source_folder}"')
|
||||||
|
|
||||||
|
processed += 1
|
||||||
|
logger.info(
|
||||||
|
f"Verarbeitet: {subject} ({len(attachments)} Anhänge)"
|
||||||
|
)
|
||||||
|
await add_log_entry(
|
||||||
|
email_subject=subject,
|
||||||
|
email_from=from_addr,
|
||||||
|
attachments_count=len(attachments),
|
||||||
|
status="success",
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
errors += 1
|
||||||
|
logger.error(f"Fehler bei Email UID {msg_uid}: {e}")
|
||||||
|
try:
|
||||||
|
await add_log_entry(
|
||||||
|
email_subject=subject if "subject" in dir() else "?",
|
||||||
|
email_from=from_addr if "from_addr" in dir() else "?",
|
||||||
|
attachments_count=0,
|
||||||
|
status="error",
|
||||||
|
error_message=str(e),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Verbindungsfehler: {e}")
|
logger.error(f"Verbindungsfehler: {e}")
|
||||||
@@ -290,7 +236,7 @@ async def process_mailbox() -> dict:
|
|||||||
status="error",
|
status="error",
|
||||||
error_message=f"Verbindungsfehler: {e}",
|
error_message=f"Verbindungsfehler: {e}",
|
||||||
)
|
)
|
||||||
return {**total, "errors": total["errors"] + 1, "error": str(e)}
|
return {"processed": processed, "skipped": skipped, "errors": errors + 1, "error": str(e)}
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
if imap_conn:
|
if imap_conn:
|
||||||
@@ -304,52 +250,36 @@ async def process_mailbox() -> dict:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
logger.info(f"Fertig: {total['processed']} verarbeitet, {total['skipped']} übersprungen, {total['errors']} Fehler")
|
logger.info(
|
||||||
return total
|
f"Fertig: {processed} verarbeitet, {skipped} übersprungen, {errors} Fehler"
|
||||||
|
)
|
||||||
|
return {"processed": processed, "skipped": skipped, "errors": errors}
|
||||||
|
|
||||||
|
|
||||||
async def send_test_email() -> dict:
|
async def send_test_email() -> dict:
|
||||||
settings = await get_settings()
|
settings = await get_settings()
|
||||||
|
|
||||||
import_email_eingang = get_import_email(settings, "eingang")
|
if not settings.get("smtp_server") or not settings.get("lexoffice_email"):
|
||||||
import_email_ausgang = get_import_email(settings, "ausgang")
|
return {"success": False, "error": "SMTP oder LexOffice-Email nicht konfiguriert"}
|
||||||
|
|
||||||
if not settings.get("smtp_server") or not import_email_eingang:
|
|
||||||
return {"success": False, "error": "SMTP oder Import-Email (Eingang) nicht konfiguriert"}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
smtp_conn = _connect_smtp(settings)
|
smtp_conn = _connect_smtp(settings)
|
||||||
smtp_logs = []
|
|
||||||
|
|
||||||
# Test Eingangsbelege
|
|
||||||
msg = MIMEMultipart()
|
msg = MIMEMultipart()
|
||||||
msg["From"] = settings["smtp_username"]
|
msg["From"] = settings["smtp_username"]
|
||||||
msg["To"] = import_email_eingang
|
msg["To"] = settings["lexoffice_email"]
|
||||||
msg["Subject"] = "Belegimport - Test-Email (Eingangsbelege)"
|
msg["Subject"] = "LexOffice Belegimport - Test-Email"
|
||||||
msg.attach(MIMEText(
|
msg.attach(MIMEText(
|
||||||
"Dies ist eine Test-Email vom Belegimport Service.\n"
|
"Dies ist eine Test-Email vom LexOffice Belegimport Service.\n"
|
||||||
"Ziel: Eingangsbelege",
|
"Wenn Sie diese Email erhalten, funktioniert die SMTP-Verbindung.",
|
||||||
"plain", "utf-8",
|
"plain",
|
||||||
|
"utf-8",
|
||||||
))
|
))
|
||||||
smtp_logs.append("=== Eingangsbelege ===")
|
|
||||||
smtp_logs.append(_send_with_log(smtp_conn, msg))
|
|
||||||
|
|
||||||
# Test Ausgangsbelege (if configured)
|
|
||||||
if import_email_ausgang:
|
|
||||||
msg2 = MIMEMultipart()
|
|
||||||
msg2["From"] = settings["smtp_username"]
|
|
||||||
msg2["To"] = import_email_ausgang
|
|
||||||
msg2["Subject"] = "Belegimport - Test-Email (Ausgangsbelege)"
|
|
||||||
msg2.attach(MIMEText(
|
|
||||||
"Dies ist eine Test-Email vom Belegimport Service.\n"
|
|
||||||
"Ziel: Ausgangsbelege",
|
|
||||||
"plain", "utf-8",
|
|
||||||
))
|
|
||||||
smtp_logs.append("=== Ausgangsbelege ===")
|
|
||||||
smtp_logs.append(_send_with_log(smtp_conn, msg2))
|
|
||||||
|
|
||||||
|
smtp_conn.send_message(msg)
|
||||||
smtp_conn.quit()
|
smtp_conn.quit()
|
||||||
return {"success": True, "smtp_log": "\n".join(smtp_logs)}
|
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Test-Email fehlgeschlagen: {e}")
|
logger.error(f"Test-Email fehlgeschlagen: {e}")
|
||||||
|
|||||||
+10
-323
@@ -6,41 +6,19 @@ from contextlib import asynccontextmanager
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, Form, UploadFile
|
from fastapi import FastAPI, Request, Form, UploadFile
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from sse_starlette.sse import EventSourceResponse
|
from sse_starlette.sse import EventSourceResponse
|
||||||
|
|
||||||
from app.database import init_db, get_settings, save_settings, get_log_entries, clear_log_entries, reset_downloaded_invoices
|
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.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.scheduler import start_scheduler, configure_job, get_scheduler_status
|
||||||
from app.scanner import process_scanned_pdf, generate_separator_pdf, UPLOAD_DIR
|
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, list_smb_subfolders
|
from app.smb_processor import process_smb_share, test_smb_connection, create_smb_folder, list_smb_folders
|
||||||
from app.ftp_processor import process_ftp, test_ftp_connection, create_ftp_folder, list_ftp_folders, list_ftp_subfolders
|
|
||||||
from app.amazon_processor import (
|
|
||||||
start_login as amazon_start_login,
|
|
||||||
submit_otp as amazon_submit_otp,
|
|
||||||
get_login_state as amazon_get_login_state,
|
|
||||||
check_session_valid as amazon_check_session,
|
|
||||||
clear_session as amazon_clear_session,
|
|
||||||
process_amazon,
|
|
||||||
start_interactive_login as amazon_start_interactive,
|
|
||||||
get_browser_screenshot as amazon_get_screenshot,
|
|
||||||
send_browser_click as amazon_browser_click,
|
|
||||||
send_browser_type as amazon_browser_type,
|
|
||||||
send_browser_key as amazon_browser_key,
|
|
||||||
close_interactive_login as amazon_close_interactive,
|
|
||||||
is_interactive_login_active as amazon_login_active,
|
|
||||||
)
|
|
||||||
from app.amazon_api import (
|
|
||||||
get_oauth_authorize_url,
|
|
||||||
exchange_auth_code,
|
|
||||||
check_api_configured,
|
|
||||||
process_amazon_api,
|
|
||||||
)
|
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=getattr(logging, os.environ.get("LOG_LEVEL", "INFO").upper(), logging.INFO),
|
level=logging.INFO,
|
||||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -54,23 +32,18 @@ async def lifespan(app: FastAPI):
|
|||||||
interval = int(settings.get("interval_minutes", 5))
|
interval = int(settings.get("interval_minutes", 5))
|
||||||
enabled = settings.get("scheduler_enabled", "false") == "true"
|
enabled = settings.get("scheduler_enabled", "false") == "true"
|
||||||
configure_job(interval, enabled)
|
configure_job(interval, enabled)
|
||||||
logger.info("Belegimport gestartet")
|
logger.info("LexOffice Belegimport gestartet")
|
||||||
yield
|
yield
|
||||||
logger.info("Belegimport beendet")
|
logger.info("LexOffice Belegimport beendet")
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Belegimport", lifespan=lifespan)
|
app = FastAPI(title="LexOffice Belegimport", lifespan=lifespan)
|
||||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def index(request: Request):
|
async def index(request: Request):
|
||||||
return templates.TemplateResponse("scan.html", {"request": request})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/settings", response_class=HTMLResponse)
|
|
||||||
async def settings_page(request: Request):
|
|
||||||
settings = await get_settings()
|
settings = await get_settings()
|
||||||
logs = await get_log_entries(limit=20)
|
logs = await get_log_entries(limit=20)
|
||||||
status = get_scheduler_status()
|
status = get_scheduler_status()
|
||||||
@@ -100,13 +73,9 @@ async def _save_form_settings(request: Request) -> dict:
|
|||||||
"smtp_ssl": form.get("smtp_ssl", "starttls"),
|
"smtp_ssl": form.get("smtp_ssl", "starttls"),
|
||||||
"smtp_username": form.get("smtp_username", ""),
|
"smtp_username": form.get("smtp_username", ""),
|
||||||
"smtp_password": form.get("smtp_password") or current.get("smtp_password", ""),
|
"smtp_password": form.get("smtp_password") or current.get("smtp_password", ""),
|
||||||
"import_email": form.get("import_email", ""),
|
"lexoffice_email": form.get("lexoffice_email", ""),
|
||||||
"import_email_eingang": form.get("import_email_eingang", ""),
|
|
||||||
"import_email_ausgang": form.get("import_email_ausgang", ""),
|
|
||||||
"source_folder": form.get("source_folder", "Rechnungen"),
|
"source_folder": form.get("source_folder", "Rechnungen"),
|
||||||
"processed_folder": form.get("processed_folder", "Rechnungen/Verarbeitet"),
|
"processed_folder": form.get("processed_folder", "Rechnungen/Verarbeitet"),
|
||||||
"source_folder_ausgang": form.get("source_folder_ausgang", ""),
|
|
||||||
"processed_folder_ausgang": form.get("processed_folder_ausgang", ""),
|
|
||||||
"interval_minutes": form.get("interval_minutes", "5"),
|
"interval_minutes": form.get("interval_minutes", "5"),
|
||||||
"scheduler_enabled": form.get("scheduler_enabled", "false"),
|
"scheduler_enabled": form.get("scheduler_enabled", "false"),
|
||||||
"fetch_since_date": form.get("fetch_since_date", ""),
|
"fetch_since_date": form.get("fetch_since_date", ""),
|
||||||
@@ -120,23 +89,7 @@ async def _save_form_settings(request: Request) -> dict:
|
|||||||
"smb_share": form.get("smb_share", ""),
|
"smb_share": form.get("smb_share", ""),
|
||||||
"smb_source_path": form.get("smb_source_path", ""),
|
"smb_source_path": form.get("smb_source_path", ""),
|
||||||
"smb_processed_path": form.get("smb_processed_path", "Verarbeitet"),
|
"smb_processed_path": form.get("smb_processed_path", "Verarbeitet"),
|
||||||
"smb_source_path_ausgang": form.get("smb_source_path_ausgang", ""),
|
|
||||||
"smb_processed_path_ausgang": form.get("smb_processed_path_ausgang", ""),
|
|
||||||
"smb_mode": form.get("smb_mode", "forward"),
|
"smb_mode": form.get("smb_mode", "forward"),
|
||||||
# FTP / SFTP
|
|
||||||
"ftp_enabled": form.get("ftp_enabled", "false"),
|
|
||||||
"ftp_protocol": form.get("ftp_protocol", "sftp"),
|
|
||||||
"ftp_server": form.get("ftp_server", ""),
|
|
||||||
"ftp_port": form.get("ftp_port", "22"),
|
|
||||||
"ftp_username": form.get("ftp_username", ""),
|
|
||||||
"ftp_password": form.get("ftp_password") or current.get("ftp_password", ""),
|
|
||||||
"ftp_source_path": form.get("ftp_source_path", ""),
|
|
||||||
"ftp_processed_path": form.get("ftp_processed_path", "Verarbeitet"),
|
|
||||||
"ftp_source_path_ausgang": form.get("ftp_source_path_ausgang", ""),
|
|
||||||
"ftp_processed_path_ausgang": form.get("ftp_processed_path_ausgang", ""),
|
|
||||||
"ftp_mode": form.get("ftp_mode", "forward"),
|
|
||||||
# Debug
|
|
||||||
"debug_save_amazon_pdfs": form.get("debug_save_amazon_pdfs", "false"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await save_settings(data)
|
await save_settings(data)
|
||||||
@@ -232,45 +185,6 @@ async def api_create_smb_folder(request: Request):
|
|||||||
return JSONResponse(result)
|
return JSONResponse(result)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/list-smb-subfolders")
|
|
||||||
async def api_list_smb_subfolders(request: Request):
|
|
||||||
parent = request.query_params.get("path", "")
|
|
||||||
result = await list_smb_subfolders(parent)
|
|
||||||
return JSONResponse(result)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/test-ftp")
|
|
||||||
async def api_test_ftp(request: Request):
|
|
||||||
await _save_form_settings(request)
|
|
||||||
result = await test_ftp_connection()
|
|
||||||
return JSONResponse(result)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/process-ftp")
|
|
||||||
async def api_process_ftp(request: Request):
|
|
||||||
await _save_form_settings(request)
|
|
||||||
result = await process_ftp()
|
|
||||||
return JSONResponse(result)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/create-ftp-folder")
|
|
||||||
async def api_create_ftp_folder(request: Request):
|
|
||||||
body = await request.json()
|
|
||||||
folder_name = body.get("folder_name", "")
|
|
||||||
result = await create_ftp_folder(folder_name)
|
|
||||||
if result["success"]:
|
|
||||||
folders_result = await list_ftp_folders()
|
|
||||||
result["folders"] = folders_result.get("folders", [])
|
|
||||||
return JSONResponse(result)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/list-ftp-subfolders")
|
|
||||||
async def api_list_ftp_subfolders(request: Request):
|
|
||||||
parent = request.query_params.get("path", "")
|
|
||||||
result = await list_ftp_subfolders(parent)
|
|
||||||
return JSONResponse(result)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/log", response_class=HTMLResponse)
|
@app.get("/log", response_class=HTMLResponse)
|
||||||
async def log_page(request: Request):
|
async def log_page(request: Request):
|
||||||
logs = await get_log_entries(limit=500)
|
logs = await get_log_entries(limit=500)
|
||||||
@@ -280,12 +194,6 @@ async def log_page(request: Request):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/clear-log")
|
|
||||||
async def api_clear_log():
|
|
||||||
count = await clear_log_entries()
|
|
||||||
return JSONResponse({"success": True, "count": count})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/status")
|
@app.get("/api/status")
|
||||||
async def api_status():
|
async def api_status():
|
||||||
return get_scheduler_status()
|
return get_scheduler_status()
|
||||||
@@ -339,7 +247,6 @@ async def scan_upload_chunk(
|
|||||||
async def scan_process(request: Request):
|
async def scan_process(request: Request):
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
upload_id = body.get("upload_id", "")
|
upload_id = body.get("upload_id", "")
|
||||||
beleg_type = body.get("beleg_type", "eingang")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
uuid.UUID(upload_id)
|
uuid.UUID(upload_id)
|
||||||
@@ -362,7 +269,7 @@ async def scan_process(request: Request):
|
|||||||
# Process in background task
|
# Process in background task
|
||||||
async def _process():
|
async def _process():
|
||||||
try:
|
try:
|
||||||
result = await process_scanned_pdf(str(pdf_path), progress_callback, beleg_type=beleg_type)
|
result = await process_scanned_pdf(str(pdf_path), progress_callback)
|
||||||
_scan_progress.setdefault(upload_id, []).append({
|
_scan_progress.setdefault(upload_id, []).append({
|
||||||
"stage": "done", "result": result
|
"stage": "done", "result": result
|
||||||
})
|
})
|
||||||
@@ -416,225 +323,5 @@ async def separator_pdf():
|
|||||||
return Response(
|
return Response(
|
||||||
content=pdf_bytes,
|
content=pdf_bytes,
|
||||||
media_type="application/pdf",
|
media_type="application/pdf",
|
||||||
headers={"Content-Disposition": "attachment; filename=Trennseite.pdf"},
|
headers={"Content-Disposition": "attachment; filename=Trennseite_LexOffice.pdf"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- Plattformen (Amazon) ---
|
|
||||||
|
|
||||||
@app.get("/platforms", response_class=HTMLResponse)
|
|
||||||
async def platforms_page(request: Request):
|
|
||||||
settings = await get_settings()
|
|
||||||
status = get_scheduler_status()
|
|
||||||
return templates.TemplateResponse("platforms.html", {
|
|
||||||
"request": request,
|
|
||||||
"settings": settings,
|
|
||||||
"status": status,
|
|
||||||
"message": None,
|
|
||||||
"message_type": None,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/amazon-settings")
|
|
||||||
async def api_amazon_settings(request: Request):
|
|
||||||
body = await request.json()
|
|
||||||
current = await get_settings()
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"amazon_enabled": body.get("amazon_enabled", "false"),
|
|
||||||
"amazon_domain": body.get("amazon_domain", "amazon.de"),
|
|
||||||
"amazon_email": body.get("amazon_email", ""),
|
|
||||||
"amazon_password": body.get("amazon_password") or current.get("amazon_password", ""),
|
|
||||||
"amazon_since_date": body.get("amazon_since_date", ""),
|
|
||||||
"amazon_mode": body.get("amazon_mode", "browser"),
|
|
||||||
"amazon_app_id": body.get("amazon_app_id", ""),
|
|
||||||
"amazon_client_id": body.get("amazon_client_id", ""),
|
|
||||||
"amazon_client_secret": body.get("amazon_client_secret") or current.get("amazon_client_secret", ""),
|
|
||||||
}
|
|
||||||
await save_settings(data)
|
|
||||||
return JSONResponse({"success": True})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/amazon-status")
|
|
||||||
async def api_amazon_status():
|
|
||||||
settings = await get_settings()
|
|
||||||
mode = settings.get("amazon_mode", "browser")
|
|
||||||
|
|
||||||
if mode == "api":
|
|
||||||
api_status = await check_api_configured()
|
|
||||||
return JSONResponse({
|
|
||||||
"mode": "api",
|
|
||||||
"session_valid": api_status.get("authorized", False),
|
|
||||||
"login_active": False,
|
|
||||||
"api_configured": api_status.get("configured", False),
|
|
||||||
"api_authorized": api_status.get("authorized", False),
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
valid = await amazon_check_session()
|
|
||||||
login_active = amazon_login_active()
|
|
||||||
return JSONResponse({
|
|
||||||
"mode": "browser",
|
|
||||||
"session_valid": valid,
|
|
||||||
"login_active": login_active,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def _get_oauth_redirect_uri(request: Request) -> str:
|
|
||||||
"""Get OAuth redirect URI from env var or request."""
|
|
||||||
base = os.environ.get("OAUTH_REDIRECT_BASE", "").rstrip("/")
|
|
||||||
if not base:
|
|
||||||
base = str(request.base_url).rstrip("/")
|
|
||||||
return f"{base}/api/amazon-oauth-callback"
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/amazon-oauth-url")
|
|
||||||
async def api_amazon_oauth_url(request: Request):
|
|
||||||
"""Generate OAuth authorization URL for Amazon Business API."""
|
|
||||||
settings = await get_settings()
|
|
||||||
app_id = settings.get("amazon_app_id", "")
|
|
||||||
if not app_id:
|
|
||||||
return JSONResponse({"error": "App-ID nicht konfiguriert"}, status_code=400)
|
|
||||||
|
|
||||||
redirect_uri = _get_oauth_redirect_uri(request)
|
|
||||||
domain = settings.get("amazon_domain", "amazon.de")
|
|
||||||
state = str(uuid.uuid4())
|
|
||||||
|
|
||||||
url = get_oauth_authorize_url(app_id, redirect_uri, domain, state)
|
|
||||||
return JSONResponse({"url": url, "state": state})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/amazon-oauth-callback")
|
|
||||||
async def api_amazon_oauth_callback(request: Request):
|
|
||||||
"""Handle OAuth callback from Amazon."""
|
|
||||||
code = request.query_params.get("spapi_oauth_code") or request.query_params.get("code", "")
|
|
||||||
error = request.query_params.get("error", "")
|
|
||||||
|
|
||||||
if error:
|
|
||||||
return HTMLResponse(f"<h2>Autorisierung fehlgeschlagen</h2><p>{error}</p><p>Fenster kann geschlossen werden.</p>")
|
|
||||||
|
|
||||||
if not code:
|
|
||||||
return HTMLResponse("<h2>Fehler: Kein Autorisierungscode erhalten</h2><p>Fenster kann geschlossen werden.</p>")
|
|
||||||
|
|
||||||
settings = await get_settings()
|
|
||||||
client_id = settings.get("amazon_client_id", "")
|
|
||||||
client_secret = settings.get("amazon_client_secret", "")
|
|
||||||
redirect_uri = _get_oauth_redirect_uri(request)
|
|
||||||
|
|
||||||
result = await exchange_auth_code(code, client_id, client_secret, redirect_uri)
|
|
||||||
|
|
||||||
if "error" in result:
|
|
||||||
return HTMLResponse(f"<h2>Token-Exchange fehlgeschlagen</h2><p>{result['error']}</p>")
|
|
||||||
|
|
||||||
refresh_token = result.get("refresh_token", "")
|
|
||||||
if refresh_token:
|
|
||||||
await save_settings({"amazon_refresh_token": refresh_token})
|
|
||||||
return HTMLResponse(
|
|
||||||
"<h2>Autorisierung erfolgreich!</h2>"
|
|
||||||
"<p>Refresh-Token wurde gespeichert. Dieses Fenster kann geschlossen werden.</p>"
|
|
||||||
"<script>window.close();</script>"
|
|
||||||
)
|
|
||||||
|
|
||||||
return HTMLResponse("<h2>Fehler: Kein Refresh-Token erhalten</h2>")
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/amazon-oauth-exchange")
|
|
||||||
async def api_amazon_oauth_exchange(request: Request):
|
|
||||||
"""Manual OAuth code exchange - user pastes the code from the redirect URL."""
|
|
||||||
body = await request.json()
|
|
||||||
code = body.get("code", "").strip()
|
|
||||||
if not code:
|
|
||||||
return JSONResponse({"error": "Kein Code angegeben"}, status_code=400)
|
|
||||||
|
|
||||||
settings = await get_settings()
|
|
||||||
client_id = settings.get("amazon_client_id", "")
|
|
||||||
client_secret = settings.get("amazon_client_secret", "")
|
|
||||||
redirect_uri = _get_oauth_redirect_uri(request)
|
|
||||||
|
|
||||||
result = await exchange_auth_code(code, client_id, client_secret, redirect_uri)
|
|
||||||
|
|
||||||
if "error" in result:
|
|
||||||
return JSONResponse({"error": result["error"]}, status_code=400)
|
|
||||||
|
|
||||||
refresh_token = result.get("refresh_token", "")
|
|
||||||
if refresh_token:
|
|
||||||
await save_settings({"amazon_refresh_token": refresh_token})
|
|
||||||
return JSONResponse({"success": True})
|
|
||||||
|
|
||||||
return JSONResponse({"error": "Kein Refresh-Token erhalten"}, status_code=400)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/amazon-login")
|
|
||||||
async def api_amazon_login():
|
|
||||||
"""Start interactive browser login."""
|
|
||||||
await amazon_start_interactive()
|
|
||||||
return JSONResponse({"success": True})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/amazon-login-state")
|
|
||||||
async def api_amazon_login_state():
|
|
||||||
return JSONResponse(amazon_get_login_state())
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/amazon-browser-screenshot")
|
|
||||||
async def api_amazon_browser_screenshot():
|
|
||||||
img = await amazon_get_screenshot()
|
|
||||||
if img is None:
|
|
||||||
return JSONResponse({"error": "Kein Browser offen"}, status_code=404)
|
|
||||||
return Response(content=img, media_type="image/png")
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/amazon-browser-click")
|
|
||||||
async def api_amazon_browser_click(request: Request):
|
|
||||||
body = await request.json()
|
|
||||||
await amazon_browser_click(int(body["x"]), int(body["y"]))
|
|
||||||
return JSONResponse({"success": True})
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/amazon-browser-type")
|
|
||||||
async def api_amazon_browser_type(request: Request):
|
|
||||||
body = await request.json()
|
|
||||||
await amazon_browser_type(body["text"])
|
|
||||||
return JSONResponse({"success": True})
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/amazon-browser-key")
|
|
||||||
async def api_amazon_browser_key(request: Request):
|
|
||||||
body = await request.json()
|
|
||||||
await amazon_browser_key(body["key"])
|
|
||||||
return JSONResponse({"success": True})
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/amazon-login-close")
|
|
||||||
async def api_amazon_login_close():
|
|
||||||
await amazon_close_interactive()
|
|
||||||
return JSONResponse({"success": True})
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/amazon-otp")
|
|
||||||
async def api_amazon_otp(request: Request):
|
|
||||||
body = await request.json()
|
|
||||||
ok = await amazon_submit_otp(body.get("code", ""))
|
|
||||||
return JSONResponse({"success": ok})
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/amazon-logout")
|
|
||||||
async def api_amazon_logout():
|
|
||||||
await amazon_clear_session()
|
|
||||||
return JSONResponse({"success": True})
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/amazon-process")
|
|
||||||
async def api_amazon_process():
|
|
||||||
settings = await get_settings()
|
|
||||||
mode = settings.get("amazon_mode", "browser")
|
|
||||||
if mode == "api":
|
|
||||||
result = await process_amazon_api()
|
|
||||||
else:
|
|
||||||
result = await process_amazon()
|
|
||||||
return JSONResponse(result)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/amazon-reset")
|
|
||||||
async def api_amazon_reset():
|
|
||||||
count = await reset_downloaded_invoices()
|
|
||||||
return JSONResponse({"success": True, "count": count})
|
|
||||||
|
|||||||
+11
-17
@@ -11,12 +11,12 @@ from pypdf import PdfReader, PdfWriter
|
|||||||
import qrcode
|
import qrcode
|
||||||
from qrcode.constants import ERROR_CORRECT_H
|
from qrcode.constants import ERROR_CORRECT_H
|
||||||
|
|
||||||
from app.database import get_settings, add_log_entry, get_import_email
|
from app.database import get_settings, add_log_entry
|
||||||
from app.mail_processor import _connect_smtp, _build_forward_email, _send_with_log
|
from app.mail_processor import _connect_smtp, _build_forward_email
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SEPARATOR_QR_CONTENT = "BELEGIMPORT-TRENNUNG"
|
SEPARATOR_QR_CONTENT = "LEXOFFICE-TRENNUNG"
|
||||||
UPLOAD_DIR = Path(os.environ.get("UPLOAD_DIR", "/data/uploads"))
|
UPLOAD_DIR = Path(os.environ.get("UPLOAD_DIR", "/data/uploads"))
|
||||||
|
|
||||||
|
|
||||||
@@ -84,13 +84,12 @@ def split_pdf(pdf_path: str, separator_pages: list[int]) -> list[bytes]:
|
|||||||
return documents
|
return documents
|
||||||
|
|
||||||
|
|
||||||
async def process_scanned_pdf(pdf_path: str, progress_callback=None, beleg_type: str = "eingang") -> dict:
|
async def process_scanned_pdf(pdf_path: str, progress_callback=None) -> dict:
|
||||||
"""Full pipeline: detect separators, split, send each document via email."""
|
"""Full pipeline: detect separators, split, send each document to LexOffice."""
|
||||||
settings = await get_settings()
|
settings = await get_settings()
|
||||||
|
|
||||||
import_email = get_import_email(settings, beleg_type)
|
if not settings.get("smtp_server") or not settings.get("lexoffice_email"):
|
||||||
if not settings.get("smtp_server") or not import_email:
|
return {"error": "SMTP oder LexOffice-Email nicht konfiguriert", "total_pages": 0, "documents": 0, "sent": 0, "errors": 1}
|
||||||
return {"error": "SMTP oder Import-Email nicht konfiguriert", "total_pages": 0, "documents": 0, "sent": 0, "errors": 1}
|
|
||||||
|
|
||||||
# Step 1: Detect separator pages (CPU-bound, run in thread)
|
# Step 1: Detect separator pages (CPU-bound, run in thread)
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
@@ -117,7 +116,7 @@ async def process_scanned_pdf(pdf_path: str, progress_callback=None, beleg_type:
|
|||||||
if not documents:
|
if not documents:
|
||||||
return {"error": "Keine Dokumente nach dem Splitting gefunden", "total_pages": total_pages, "documents": 0, "sent": 0, "errors": 1}
|
return {"error": "Keine Dokumente nach dem Splitting gefunden", "total_pages": total_pages, "documents": 0, "sent": 0, "errors": 1}
|
||||||
|
|
||||||
# Step 3: Send each document via email
|
# Step 3: Send each document to LexOffice
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
progress_callback("status", 0, 0, f"{len(documents)} Dokument(e) erkannt, starte Versand...")
|
progress_callback("status", 0, 0, f"{len(documents)} Dokument(e) erkannt, starte Versand...")
|
||||||
|
|
||||||
@@ -136,12 +135,12 @@ async def process_scanned_pdf(pdf_path: str, progress_callback=None, beleg_type:
|
|||||||
filename = f"Scan_Dokument_{i + 1}.pdf"
|
filename = f"Scan_Dokument_{i + 1}.pdf"
|
||||||
msg = _build_forward_email(
|
msg = _build_forward_email(
|
||||||
from_addr=settings["smtp_username"],
|
from_addr=settings["smtp_username"],
|
||||||
to_addr=import_email,
|
to_addr=settings["lexoffice_email"],
|
||||||
original_subject=f"Scan-Upload Dokument {i + 1}/{len(documents)}",
|
original_subject=f"Scan-Upload Dokument {i + 1}/{len(documents)}",
|
||||||
original_from="Scan-Upload",
|
original_from="Scan-Upload",
|
||||||
attachments=[(filename, doc_bytes)],
|
attachments=[(filename, doc_bytes)],
|
||||||
)
|
)
|
||||||
smtp_log = _send_with_log(smtp_conn, msg)
|
smtp_conn.send_message(msg)
|
||||||
sent += 1
|
sent += 1
|
||||||
|
|
||||||
await add_log_entry(
|
await add_log_entry(
|
||||||
@@ -149,9 +148,6 @@ async def process_scanned_pdf(pdf_path: str, progress_callback=None, beleg_type:
|
|||||||
email_from="Scan-Upload",
|
email_from="Scan-Upload",
|
||||||
attachments_count=1,
|
attachments_count=1,
|
||||||
status="success",
|
status="success",
|
||||||
sent_to=import_email,
|
|
||||||
smtp_log=smtp_log,
|
|
||||||
beleg_type=beleg_type,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -163,8 +159,6 @@ async def process_scanned_pdf(pdf_path: str, progress_callback=None, beleg_type:
|
|||||||
attachments_count=1,
|
attachments_count=1,
|
||||||
status="error",
|
status="error",
|
||||||
error_message=str(e),
|
error_message=str(e),
|
||||||
sent_to=import_email,
|
|
||||||
beleg_type=beleg_type,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -223,7 +217,7 @@ def generate_separator_pdf() -> bytes:
|
|||||||
|
|
||||||
# Title text
|
# Title text
|
||||||
_centered_textbox(page, 120, "TRENNSEITE", 36, (0, 0, 0))
|
_centered_textbox(page, 120, "TRENNSEITE", 36, (0, 0, 0))
|
||||||
_centered_textbox(page, 170, "Belegimport", 16, (0.4, 0.4, 0.4))
|
_centered_textbox(page, 170, "LexOffice Belegimport", 16, (0.4, 0.4, 0.4))
|
||||||
|
|
||||||
# Insert QR code image centered
|
# Insert QR code image centered
|
||||||
qr_bytes = io.BytesIO()
|
qr_bytes = io.BytesIO()
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ from apscheduler.triggers.interval import IntervalTrigger
|
|||||||
|
|
||||||
from app.mail_processor import process_mailbox
|
from app.mail_processor import process_mailbox
|
||||||
from app.smb_processor import process_smb_share
|
from app.smb_processor import process_smb_share
|
||||||
from app.ftp_processor import process_ftp
|
|
||||||
from app.amazon_processor import process_amazon
|
|
||||||
from app.amazon_api import process_amazon_api
|
|
||||||
from app.database import get_settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -25,7 +21,6 @@ async def _run_processor():
|
|||||||
return
|
return
|
||||||
_is_processing = True
|
_is_processing = True
|
||||||
try:
|
try:
|
||||||
# Email and SMB first - these are fast and must not be blocked by Amazon
|
|
||||||
logger.info("Starte automatische Email-Verarbeitung...")
|
logger.info("Starte automatische Email-Verarbeitung...")
|
||||||
result = await process_mailbox()
|
result = await process_mailbox()
|
||||||
logger.info(f"Email-Verarbeitung abgeschlossen: {result}")
|
logger.info(f"Email-Verarbeitung abgeschlossen: {result}")
|
||||||
@@ -33,25 +28,6 @@ async def _run_processor():
|
|||||||
logger.info("Starte automatische SMB-Verarbeitung...")
|
logger.info("Starte automatische SMB-Verarbeitung...")
|
||||||
smb_result = await process_smb_share()
|
smb_result = await process_smb_share()
|
||||||
logger.info(f"SMB-Verarbeitung abgeschlossen: {smb_result}")
|
logger.info(f"SMB-Verarbeitung abgeschlossen: {smb_result}")
|
||||||
|
|
||||||
logger.info("Starte automatische FTP-Verarbeitung...")
|
|
||||||
ftp_result = await process_ftp()
|
|
||||||
logger.info(f"FTP-Verarbeitung abgeschlossen: {ftp_result}")
|
|
||||||
|
|
||||||
# Amazon separately with timeout - must not block next scheduler runs
|
|
||||||
logger.info("Starte automatische Amazon-Verarbeitung...")
|
|
||||||
try:
|
|
||||||
settings = await get_settings()
|
|
||||||
amazon_mode = settings.get("amazon_mode", "browser")
|
|
||||||
if amazon_mode == "api":
|
|
||||||
amazon_result = await asyncio.wait_for(process_amazon_api(), timeout=300)
|
|
||||||
else:
|
|
||||||
amazon_result = await asyncio.wait_for(process_amazon(), timeout=300)
|
|
||||||
logger.info(f"Amazon-Verarbeitung abgeschlossen: {amazon_result}")
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.error("Amazon-Verarbeitung nach 5 Minuten abgebrochen (Timeout)")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Fehler bei Amazon-Verarbeitung: {e}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Fehler bei automatischer Verarbeitung: {e}")
|
logger.error(f"Fehler bei automatischer Verarbeitung: {e}")
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
+110
-180
@@ -5,8 +5,8 @@ import tempfile
|
|||||||
|
|
||||||
import smbclient
|
import smbclient
|
||||||
|
|
||||||
from app.database import get_settings, add_log_entry, get_import_email
|
from app.database import get_settings, add_log_entry
|
||||||
from app.mail_processor import _connect_smtp, _build_forward_email, _send_with_log
|
from app.mail_processor import _connect_smtp, _build_forward_email
|
||||||
from app.scanner import detect_separator_pages, split_pdf
|
from app.scanner import detect_separator_pages, split_pdf
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -66,14 +66,8 @@ def _list_pdf_files(source_path: str) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _read_smb_file(filepath: str) -> bytes:
|
def _read_smb_file(filepath: str) -> bytes:
|
||||||
"""Read a file from SMB share into memory.
|
"""Read a file from SMB share into memory."""
|
||||||
|
with smbclient.open_file(filepath, mode="rb") as f:
|
||||||
share_access="rwd" allows concurrent read/write/delete operations on the same
|
|
||||||
file, which is required because smbclient keeps the underlying SMB connection
|
|
||||||
in a pool. Without this, subsequent rename/delete on the file fails with
|
|
||||||
STATUS_ACCESS_DENIED until the session is closed.
|
|
||||||
"""
|
|
||||||
with smbclient.open_file(filepath, mode="rb", share_access="rwd") as f:
|
|
||||||
return f.read()
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
@@ -99,7 +93,7 @@ def _move_smb_file(source: str, dest_dir: str, filename: str):
|
|||||||
|
|
||||||
|
|
||||||
def _list_smb_folders_recursive(
|
def _list_smb_folders_recursive(
|
||||||
base_path: str, max_depth: int = 5, _current_depth: int = 0, _prefix: str = ""
|
base_path: str, max_depth: int = 3, _current_depth: int = 0, _prefix: str = ""
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Recursively list folders on the SMB share, returning relative paths."""
|
"""Recursively list folders on the SMB share, returning relative paths."""
|
||||||
folders = []
|
folders = []
|
||||||
@@ -120,119 +114,6 @@ def _list_smb_folders_recursive(
|
|||||||
return folders
|
return folders
|
||||||
|
|
||||||
|
|
||||||
async def _process_smb_folder(
|
|
||||||
smtp_conn, settings: dict, base_path: str,
|
|
||||||
source_rel: str, processed_rel: str,
|
|
||||||
import_email: str, beleg_type: str, mode: str,
|
|
||||||
) -> dict:
|
|
||||||
"""Process one SMB folder pair. Returns counts dict."""
|
|
||||||
smtp_from = settings.get("smtp_username", "")
|
|
||||||
processed = 0
|
|
||||||
skipped = 0
|
|
||||||
errors = 0
|
|
||||||
|
|
||||||
source_path = _smb_unc_path(base_path, source_rel)
|
|
||||||
processed_path = _smb_unc_path(base_path, processed_rel)
|
|
||||||
|
|
||||||
await asyncio.to_thread(_ensure_smb_folder, processed_path)
|
|
||||||
|
|
||||||
pdf_files = await asyncio.to_thread(_list_pdf_files, source_path)
|
|
||||||
if not pdf_files:
|
|
||||||
logger.info(f"Keine PDF-Dateien im SMB-Ordner '{source_rel}' ({beleg_type})")
|
|
||||||
return {"processed": 0, "skipped": 0, "errors": 0}
|
|
||||||
|
|
||||||
logger.info(f"{len(pdf_files)} PDF-Datei(en) im SMB-Ordner '{source_rel}' ({beleg_type})")
|
|
||||||
|
|
||||||
for filename in pdf_files:
|
|
||||||
file_path = _smb_unc_path(source_path, filename)
|
|
||||||
try:
|
|
||||||
pdf_data = await asyncio.to_thread(_read_smb_file, file_path)
|
|
||||||
|
|
||||||
if mode == "separator":
|
|
||||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
|
|
||||||
tmp.write(pdf_data)
|
|
||||||
tmp_path = tmp.name
|
|
||||||
try:
|
|
||||||
separator_pages = await asyncio.to_thread(
|
|
||||||
detect_separator_pages, tmp_path, None
|
|
||||||
)
|
|
||||||
documents = await asyncio.to_thread(
|
|
||||||
split_pdf, tmp_path, separator_pages
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
os.unlink(tmp_path)
|
|
||||||
|
|
||||||
if not documents:
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
smtp_log_parts = []
|
|
||||||
for i, doc_bytes in enumerate(documents):
|
|
||||||
doc_filename = f"{os.path.splitext(filename)[0]}_Teil_{i + 1}.pdf"
|
|
||||||
subject = f"SMB-Import: {filename} (Dokument {i + 1}/{len(documents)})"
|
|
||||||
msg = _build_forward_email(
|
|
||||||
from_addr=smtp_from,
|
|
||||||
to_addr=import_email,
|
|
||||||
original_subject=subject,
|
|
||||||
original_from="SMB-Import",
|
|
||||||
attachments=[(doc_filename, doc_bytes)],
|
|
||||||
)
|
|
||||||
smtp_log_parts.append(_send_with_log(smtp_conn, msg))
|
|
||||||
|
|
||||||
await add_log_entry(
|
|
||||||
email_subject=f"SMB: {filename}",
|
|
||||||
email_from="SMB-Import",
|
|
||||||
attachments_count=len(documents),
|
|
||||||
status="success",
|
|
||||||
sent_to=import_email,
|
|
||||||
smtp_log="\n---\n".join(smtp_log_parts),
|
|
||||||
beleg_type=beleg_type,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
f"SMB verarbeitet ({beleg_type}): {filename} -> {len(documents)} Dokument(e)"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
msg = _build_forward_email(
|
|
||||||
from_addr=smtp_from,
|
|
||||||
to_addr=import_email,
|
|
||||||
original_subject=f"SMB-Import: {filename}",
|
|
||||||
original_from="SMB-Import",
|
|
||||||
attachments=[(filename, pdf_data)],
|
|
||||||
)
|
|
||||||
smtp_log = _send_with_log(smtp_conn, msg)
|
|
||||||
|
|
||||||
await add_log_entry(
|
|
||||||
email_subject=f"SMB: {filename}",
|
|
||||||
email_from="SMB-Import",
|
|
||||||
attachments_count=1,
|
|
||||||
status="success",
|
|
||||||
sent_to=import_email,
|
|
||||||
smtp_log=smtp_log,
|
|
||||||
beleg_type=beleg_type,
|
|
||||||
)
|
|
||||||
logger.info(f"SMB verarbeitet ({beleg_type}): {filename}")
|
|
||||||
|
|
||||||
await asyncio.to_thread(_move_smb_file, file_path, processed_path, filename)
|
|
||||||
processed += 1
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
errors += 1
|
|
||||||
logger.error(f"Fehler bei SMB-Datei {filename}: {e}")
|
|
||||||
try:
|
|
||||||
await add_log_entry(
|
|
||||||
email_subject=f"SMB: {filename}",
|
|
||||||
email_from="SMB-Import",
|
|
||||||
attachments_count=0,
|
|
||||||
status="error",
|
|
||||||
error_message=str(e),
|
|
||||||
beleg_type=beleg_type,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {"processed": processed, "skipped": skipped, "errors": errors}
|
|
||||||
|
|
||||||
|
|
||||||
async def process_smb_share() -> dict:
|
async def process_smb_share() -> dict:
|
||||||
"""Process PDF files from SMB share - main pipeline."""
|
"""Process PDF files from SMB share - main pipeline."""
|
||||||
settings = await get_settings()
|
settings = await get_settings()
|
||||||
@@ -243,43 +124,113 @@ async def process_smb_share() -> dict:
|
|||||||
if not settings.get("smb_server") or not settings.get("smb_share"):
|
if not settings.get("smb_server") or not settings.get("smb_share"):
|
||||||
return {"processed": 0, "skipped": 0, "errors": 0, "error": "SMB nicht konfiguriert"}
|
return {"processed": 0, "skipped": 0, "errors": 0, "error": "SMB nicht konfiguriert"}
|
||||||
|
|
||||||
import_email_eingang = get_import_email(settings, "eingang")
|
if not settings.get("lexoffice_email"):
|
||||||
if not import_email_eingang:
|
return {"processed": 0, "skipped": 0, "errors": 0, "error": "LexOffice-Email nicht konfiguriert"}
|
||||||
return {"processed": 0, "skipped": 0, "errors": 0, "error": "Import-Email nicht konfiguriert"}
|
|
||||||
|
|
||||||
mode = settings.get("smb_mode", "forward")
|
mode = settings.get("smb_mode", "forward")
|
||||||
total = {"processed": 0, "skipped": 0, "errors": 0}
|
smtp_from = settings.get("smtp_username", "")
|
||||||
|
lexoffice_email = settings["lexoffice_email"]
|
||||||
|
|
||||||
|
processed = 0
|
||||||
|
skipped = 0
|
||||||
|
errors = 0
|
||||||
smtp_conn = None
|
smtp_conn = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
base_path = await asyncio.to_thread(_smb_register_session, settings)
|
base_path = await asyncio.to_thread(_smb_register_session, settings)
|
||||||
|
source_path = _smb_unc_path(base_path, settings.get("smb_source_path", ""))
|
||||||
|
processed_path = _smb_unc_path(base_path, settings.get("smb_processed_path", "Verarbeitet"))
|
||||||
|
|
||||||
|
await asyncio.to_thread(_ensure_smb_folder, processed_path)
|
||||||
|
|
||||||
|
pdf_files = await asyncio.to_thread(_list_pdf_files, source_path)
|
||||||
|
if not pdf_files:
|
||||||
|
logger.info("Keine PDF-Dateien im SMB-Ordner gefunden")
|
||||||
|
return {"processed": 0, "skipped": 0, "errors": 0}
|
||||||
|
|
||||||
|
logger.info(f"{len(pdf_files)} PDF-Datei(en) im SMB-Ordner gefunden")
|
||||||
|
|
||||||
smtp_conn = _connect_smtp(settings)
|
smtp_conn = _connect_smtp(settings)
|
||||||
|
|
||||||
# Eingangsbelege
|
for filename in pdf_files:
|
||||||
source = settings.get("smb_source_path", "")
|
file_path = _smb_unc_path(source_path, filename)
|
||||||
processed_rel = settings.get("smb_processed_path", "Verarbeitet")
|
try:
|
||||||
result = await _process_smb_folder(
|
pdf_data = await asyncio.to_thread(_read_smb_file, file_path)
|
||||||
smtp_conn, settings, base_path,
|
|
||||||
source, processed_rel,
|
|
||||||
import_email_eingang, "eingang", mode,
|
|
||||||
)
|
|
||||||
for k in total:
|
|
||||||
total[k] += result[k]
|
|
||||||
|
|
||||||
# Ausgangsbelege (optional)
|
if mode == "separator":
|
||||||
import_email_ausgang = get_import_email(settings, "ausgang")
|
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
|
||||||
source_ausgang = settings.get("smb_source_path_ausgang", "")
|
tmp.write(pdf_data)
|
||||||
processed_ausgang = settings.get("smb_processed_path_ausgang", "")
|
tmp_path = tmp.name
|
||||||
if import_email_ausgang and source_ausgang:
|
try:
|
||||||
if not processed_ausgang:
|
separator_pages = await asyncio.to_thread(
|
||||||
processed_ausgang = source_ausgang + "/Verarbeitet"
|
detect_separator_pages, tmp_path, None
|
||||||
result = await _process_smb_folder(
|
)
|
||||||
smtp_conn, settings, base_path,
|
documents = await asyncio.to_thread(
|
||||||
source_ausgang, processed_ausgang,
|
split_pdf, tmp_path, separator_pages
|
||||||
import_email_ausgang, "ausgang", mode,
|
)
|
||||||
)
|
finally:
|
||||||
for k in total:
|
os.unlink(tmp_path)
|
||||||
total[k] += result[k]
|
|
||||||
|
if not documents:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
for i, doc_bytes in enumerate(documents):
|
||||||
|
doc_filename = f"{os.path.splitext(filename)[0]}_Teil_{i + 1}.pdf"
|
||||||
|
subject = f"SMB-Import: {filename} (Dokument {i + 1}/{len(documents)})"
|
||||||
|
msg = _build_forward_email(
|
||||||
|
from_addr=smtp_from,
|
||||||
|
to_addr=lexoffice_email,
|
||||||
|
original_subject=subject,
|
||||||
|
original_from="SMB-Import",
|
||||||
|
attachments=[(doc_filename, doc_bytes)],
|
||||||
|
)
|
||||||
|
smtp_conn.send_message(msg)
|
||||||
|
|
||||||
|
await add_log_entry(
|
||||||
|
email_subject=f"SMB: {filename}",
|
||||||
|
email_from="SMB-Import",
|
||||||
|
attachments_count=len(documents),
|
||||||
|
status="success",
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"SMB verarbeitet: {filename} -> {len(documents)} Dokument(e) "
|
||||||
|
f"({len(separator_pages)} Trennseite(n))"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
msg = _build_forward_email(
|
||||||
|
from_addr=smtp_from,
|
||||||
|
to_addr=lexoffice_email,
|
||||||
|
original_subject=f"SMB-Import: {filename}",
|
||||||
|
original_from="SMB-Import",
|
||||||
|
attachments=[(filename, pdf_data)],
|
||||||
|
)
|
||||||
|
smtp_conn.send_message(msg)
|
||||||
|
|
||||||
|
await add_log_entry(
|
||||||
|
email_subject=f"SMB: {filename}",
|
||||||
|
email_from="SMB-Import",
|
||||||
|
attachments_count=1,
|
||||||
|
status="success",
|
||||||
|
)
|
||||||
|
logger.info(f"SMB verarbeitet: {filename}")
|
||||||
|
|
||||||
|
await asyncio.to_thread(_move_smb_file, file_path, processed_path, filename)
|
||||||
|
processed += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
errors += 1
|
||||||
|
logger.error(f"Fehler bei SMB-Datei {filename}: {e}")
|
||||||
|
try:
|
||||||
|
await add_log_entry(
|
||||||
|
email_subject=f"SMB: {filename}",
|
||||||
|
email_from="SMB-Import",
|
||||||
|
attachments_count=0,
|
||||||
|
status="error",
|
||||||
|
error_message=str(e),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"SMB-Verbindungsfehler: {e}")
|
logger.error(f"SMB-Verbindungsfehler: {e}")
|
||||||
@@ -293,7 +244,7 @@ async def process_smb_share() -> dict:
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return {**total, "errors": total["errors"] + 1, "error": str(e)}
|
return {"processed": processed, "skipped": skipped, "errors": errors + 1, "error": str(e)}
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
if smtp_conn:
|
if smtp_conn:
|
||||||
@@ -302,12 +253,12 @@ async def process_smb_share() -> dict:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
logger.info(f"SMB fertig: {total['processed']} verarbeitet, {total['skipped']} übersprungen, {total['errors']} Fehler")
|
logger.info(f"SMB fertig: {processed} verarbeitet, {skipped} übersprungen, {errors} Fehler")
|
||||||
return total
|
return {"processed": processed, "skipped": skipped, "errors": errors}
|
||||||
|
|
||||||
|
|
||||||
async def test_smb_connection() -> dict:
|
async def test_smb_connection() -> dict:
|
||||||
"""Test SMB connection and return TOP-LEVEL folders only (lazy loading)."""
|
"""Test SMB connection and return folder list."""
|
||||||
settings = await get_settings()
|
settings = await get_settings()
|
||||||
|
|
||||||
if not settings.get("smb_server") or not settings.get("smb_share"):
|
if not settings.get("smb_server") or not settings.get("smb_share"):
|
||||||
@@ -315,7 +266,7 @@ async def test_smb_connection() -> dict:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
base_path = await asyncio.to_thread(_smb_register_session, settings)
|
base_path = await asyncio.to_thread(_smb_register_session, settings)
|
||||||
folders = await asyncio.to_thread(_list_smb_folders_recursive, base_path, 1)
|
folders = await asyncio.to_thread(_list_smb_folders_recursive, base_path, 3)
|
||||||
return {"success": True, "folders": sorted(folders)}
|
return {"success": True, "folders": sorted(folders)}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -347,34 +298,13 @@ async def create_smb_folder(folder_path: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
async def list_smb_folders() -> dict:
|
async def list_smb_folders() -> dict:
|
||||||
"""Return TOP-LEVEL folder list from SMB share (lazy loading)."""
|
"""Return current folder list from SMB share."""
|
||||||
settings = await get_settings()
|
settings = await get_settings()
|
||||||
if not settings.get("smb_server") or not settings.get("smb_share"):
|
if not settings.get("smb_server") or not settings.get("smb_share"):
|
||||||
return {"folders": []}
|
return {"folders": []}
|
||||||
try:
|
try:
|
||||||
base_path = await asyncio.to_thread(_smb_register_session, settings)
|
base_path = await asyncio.to_thread(_smb_register_session, settings)
|
||||||
folders = await asyncio.to_thread(_list_smb_folders_recursive, base_path, 1)
|
folders = await asyncio.to_thread(_list_smb_folders_recursive, base_path, 3)
|
||||||
return {"folders": sorted(folders)}
|
return {"folders": sorted(folders)}
|
||||||
except Exception:
|
except Exception:
|
||||||
return {"folders": []}
|
return {"folders": []}
|
||||||
|
|
||||||
|
|
||||||
async def list_smb_subfolders(parent_path: str) -> dict:
|
|
||||||
"""List direct subfolders of a path (one level deep, for lazy tree expansion)."""
|
|
||||||
settings = await get_settings()
|
|
||||||
if not settings.get("smb_server") or not settings.get("smb_share"):
|
|
||||||
return {"success": False, "error": "SMB nicht konfiguriert", "folders": []}
|
|
||||||
try:
|
|
||||||
base_path = await asyncio.to_thread(_smb_register_session, settings)
|
|
||||||
full_path = _smb_unc_path(base_path, parent_path) if parent_path else base_path
|
|
||||||
# max_depth=1 returns only direct children
|
|
||||||
rel_folders = await asyncio.to_thread(_list_smb_folders_recursive, full_path, 1)
|
|
||||||
# Prefix with parent_path so the frontend has full paths
|
|
||||||
if parent_path:
|
|
||||||
folders = [f"{parent_path}/{f}" for f in rel_folders]
|
|
||||||
else:
|
|
||||||
folders = rel_folders
|
|
||||||
return {"success": True, "folders": sorted(folders)}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"SMB-Subfolder-Liste fehlgeschlagen: {e}")
|
|
||||||
return {"success": False, "error": str(e), "folders": []}
|
|
||||||
|
|||||||
@@ -200,11 +200,6 @@ main {
|
|||||||
color: #856404;
|
color: #856404;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-info {
|
|
||||||
background: #d1ecf1;
|
|
||||||
color: #0c5460;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-inactive {
|
.badge-inactive {
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: rgba(255, 255, 255, 0.2);
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
@@ -241,16 +236,6 @@ main {
|
|||||||
border: 1px solid #bee5eb;
|
border: 1px solid #bee5eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Allow cards with tables to scroll horizontally */
|
|
||||||
.card-table {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Wider main container for pages with large tables */
|
|
||||||
.main-wide {
|
|
||||||
max-width: 95%;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -424,35 +409,6 @@ small.text-muted {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tree-View Toggle */
|
|
||||||
.folder-tree-toggle {
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
width: 1.5rem;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-tree-toggle:hover {
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-tree-toggle.empty {
|
|
||||||
cursor: default;
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-tree-children {
|
|
||||||
margin-left: 1.5rem;
|
|
||||||
border-left: 1px dashed var(--border);
|
|
||||||
padding-left: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-item {
|
.folder-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -3,16 +3,15 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Belegimport</title>
|
<title>LexOffice Belegimport</title>
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav>
|
<nav>
|
||||||
<div class="nav-brand">Belegimport</div>
|
<div class="nav-brand">LexOffice Belegimport</div>
|
||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
<a href="/" class="{% if active_page == 'scan' %}active{% endif %}">Scan-Upload</a>
|
<a href="/" class="{% if active_page == 'settings' %}active{% endif %}">Einstellungen</a>
|
||||||
<a href="/settings" class="{% if active_page == 'settings' %}active{% endif %}">Einstellungen</a>
|
<a href="/scan" class="{% if active_page == 'scan' %}active{% endif %}">Scan-Upload</a>
|
||||||
<a href="/platforms" class="{% if active_page == 'platforms' %}active{% endif %}">Plattformen</a>
|
|
||||||
<a href="/log" class="{% if active_page == 'log' %}active{% endif %}">Verarbeitungslog</a>
|
<a href="/log" class="{% if active_page == 'log' %}active{% endif %}">Verarbeitungslog</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-status">
|
<div class="nav-status">
|
||||||
@@ -30,7 +29,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main class="{% if main_class is defined %}{{ main_class }}{% endif %}">
|
<main>
|
||||||
{% if message %}
|
{% if message %}
|
||||||
<div class="alert alert-{{ message_type or 'info' }}">
|
<div class="alert alert-{{ message_type or 'info' }}">
|
||||||
{{ message }}
|
{{ message }}
|
||||||
|
|||||||
+2
-75
@@ -1,30 +1,21 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% set active_page = "log" %}
|
{% set active_page = "log" %}
|
||||||
{% set main_class = "main-wide" %}
|
|
||||||
{% set message = None %}
|
{% set message = None %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="card card-table">
|
<div class="card">
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;padding-bottom:0.5rem;border-bottom:1px solid var(--border);">
|
<h2>Verarbeitungslog</h2>
|
||||||
<h2 style="margin:0;border:none;padding:0;">Verarbeitungslog</h2>
|
|
||||||
{% if logs %}
|
|
||||||
<button type="button" class="btn btn-secondary btn-small" onclick="clearLog()">Log leeren</button>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if logs %}
|
{% if logs %}
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Zeitpunkt</th>
|
<th>Zeitpunkt</th>
|
||||||
<th>Art</th>
|
|
||||||
<th>Betreff</th>
|
<th>Betreff</th>
|
||||||
<th>Absender</th>
|
<th>Absender</th>
|
||||||
<th>Anhänge</th>
|
<th>Anhänge</th>
|
||||||
<th>Gesendet an</th>
|
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Fehlermeldung</th>
|
<th>Fehlermeldung</th>
|
||||||
<th>SMTP</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -32,17 +23,9 @@
|
|||||||
<tr class="{% if log.status == 'error' %}row-error{% endif %}">
|
<tr class="{% if log.status == 'error' %}row-error{% endif %}">
|
||||||
<td>{{ log.id }}</td>
|
<td>{{ log.id }}</td>
|
||||||
<td>{{ log.timestamp }}</td>
|
<td>{{ log.timestamp }}</td>
|
||||||
<td>
|
|
||||||
{% if log.get('beleg_type', 'eingang') == 'ausgang' %}
|
|
||||||
<span class="badge badge-warning">Ausgang</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge badge-info">Eingang</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>{{ log.email_subject or '-' }}</td>
|
<td>{{ log.email_subject or '-' }}</td>
|
||||||
<td>{{ log.email_from or '-' }}</td>
|
<td>{{ log.email_from or '-' }}</td>
|
||||||
<td>{{ log.attachments_count }}</td>
|
<td>{{ log.attachments_count }}</td>
|
||||||
<td>{{ log.sent_to or '-' }}</td>
|
|
||||||
<td>
|
<td>
|
||||||
{% if log.status == 'success' %}
|
{% if log.status == 'success' %}
|
||||||
<span class="badge badge-success">OK</span>
|
<span class="badge badge-success">OK</span>
|
||||||
@@ -51,12 +34,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ log.error_message or '-' }}</td>
|
<td>{{ log.error_message or '-' }}</td>
|
||||||
<td>
|
|
||||||
{% if log.smtp_log %}
|
|
||||||
<button type="button" class="btn btn-small btn-secondary" onclick="showSmtpLog({{ log.id }})">Anzeigen</button>
|
|
||||||
<script>window._smtpLogs = window._smtpLogs || {}; window._smtpLogs[{{ log.id }}] = {{ log.smtp_log | tojson }};</script>
|
|
||||||
{% else %}-{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -65,54 +42,4 @@
|
|||||||
<p class="text-muted">Noch keine Einträge vorhanden.</p>
|
<p class="text-muted">Noch keine Einträge vorhanden.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SMTP Log Modal -->
|
|
||||||
<div id="smtpModal" class="modal-overlay" style="display:none;" onclick="if(event.target===this)closeSmtpModal()">
|
|
||||||
<div class="modal" style="max-width:700px;">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3>SMTP-Protokoll</h3>
|
|
||||||
<button type="button" class="modal-close" onclick="closeSmtpModal()">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body" style="padding:1.25rem;background:var(--bg,#f5f5f5);">
|
|
||||||
<pre id="smtpModalBody" style="margin:0;font-size:0.8rem;white-space:pre-wrap;word-break:break-all;"></pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.btn-small {
|
|
||||||
padding: 0.25rem 0.6rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function showSmtpLog(logId) {
|
|
||||||
const log = window._smtpLogs && window._smtpLogs[logId];
|
|
||||||
if (!log) return;
|
|
||||||
document.getElementById('smtpModalBody').textContent = log;
|
|
||||||
document.getElementById('smtpModal').style.display = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeSmtpModal() {
|
|
||||||
document.getElementById('smtpModal').style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Escape') closeSmtpModal();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function clearLog() {
|
|
||||||
if (!confirm('Verarbeitungslog wirklich leeren?')) return;
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/clear-log', {method: 'POST'});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.success) {
|
|
||||||
location.reload();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
alert('Fehler: ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,530 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% set active_page = "platforms" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
{% if message %}
|
|
||||||
<div class="alert alert-{{ message_type or 'info' }}">{{ message }}</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>Amazon Business - Einstellungen</h2>
|
|
||||||
<div class="form-grid">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="amazon_enabled">Status</label>
|
|
||||||
<select id="amazon_enabled" name="amazon_enabled">
|
|
||||||
<option value="false" {% if settings.get('amazon_enabled') != 'true' %}selected{% endif %}>Deaktiviert</option>
|
|
||||||
<option value="true" {% if settings.get('amazon_enabled') == 'true' %}selected{% endif %}>Aktiviert</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="amazon_mode">Abruf-Modus</label>
|
|
||||||
<select id="amazon_mode" name="amazon_mode" onchange="toggleAmazonMode()">
|
|
||||||
<option value="api" {% if settings.get('amazon_mode') == 'api' %}selected{% endif %}>API (empfohlen)</option>
|
|
||||||
<option value="browser" {% if settings.get('amazon_mode', 'browser') == 'browser' %}selected{% endif %}>Browser-Automation</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="amazon_domain">Amazon-Domain</label>
|
|
||||||
<select id="amazon_domain" name="amazon_domain">
|
|
||||||
<option value="amazon.de" {% if settings.get('amazon_domain') == 'amazon.de' %}selected{% endif %}>amazon.de</option>
|
|
||||||
<option value="amazon.at" {% if settings.get('amazon_domain') == 'amazon.at' %}selected{% endif %}>amazon.at</option>
|
|
||||||
<option value="amazon.com" {% if settings.get('amazon_domain') == 'amazon.com' %}selected{% endif %}>amazon.com</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="amazon_since_date">Rechnungen ab Datum</label>
|
|
||||||
<input type="date" id="amazon_since_date" name="amazon_since_date"
|
|
||||||
value="{{ settings.get('amazon_since_date', '') }}">
|
|
||||||
<small class="text-muted">Leer = letzte 30 Tage</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- API Mode Fields -->
|
|
||||||
<div id="apiFields" style="{% if settings.get('amazon_mode', 'browser') != 'api' %}display:none;{% endif %}margin-top:1rem;">
|
|
||||||
<h3 style="font-size:1rem;margin-bottom:0.75rem;">API-Zugangsdaten (Amazon Business API)</h3>
|
|
||||||
<div class="form-grid">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="amazon_app_id">App-ID</label>
|
|
||||||
<input type="text" id="amazon_app_id" name="amazon_app_id"
|
|
||||||
value="{{ settings.get('amazon_app_id', '') }}"
|
|
||||||
placeholder="amzn1.sp.solution.xxxxx">
|
|
||||||
<small class="text-muted">Aus dem Solution Provider Portal</small>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="amazon_client_id">Client-ID (LWA)</label>
|
|
||||||
<input type="text" id="amazon_client_id" name="amazon_client_id"
|
|
||||||
value="{{ settings.get('amazon_client_id', '') }}"
|
|
||||||
placeholder="amzn1.application-oa2-client.xxxxx">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="amazon_client_secret">Client-Sicherheitsschluessel (LWA)</label>
|
|
||||||
<input type="password" id="amazon_client_secret" name="amazon_client_secret"
|
|
||||||
placeholder="{% if settings.get('amazon_client_secret') %}(gespeichert){% else %}Client Secret eingeben{% endif %}">
|
|
||||||
</div>
|
|
||||||
<div class="form-group" style="align-self:end;">
|
|
||||||
<small class="text-muted">
|
|
||||||
{% if settings.get('amazon_refresh_token') %}
|
|
||||||
Refresh-Token: gespeichert
|
|
||||||
{% else %}
|
|
||||||
Refresh-Token: fehlt - bitte autorisieren
|
|
||||||
{% endif %}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Browser Mode Fields -->
|
|
||||||
<div id="browserFields" style="{% if settings.get('amazon_mode', 'browser') != 'browser' %}display:none;{% endif %}margin-top:1rem;">
|
|
||||||
<h3 style="font-size:1rem;margin-bottom:0.75rem;">Browser-Zugangsdaten</h3>
|
|
||||||
<div class="form-grid">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="amazon_email">Amazon E-Mail</label>
|
|
||||||
<input type="email" id="amazon_email" name="amazon_email"
|
|
||||||
value="{{ settings.get('amazon_email', '') }}"
|
|
||||||
placeholder="email@example.com">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="amazon_password">Amazon Passwort</label>
|
|
||||||
<input type="password" id="amazon_password" name="amazon_password"
|
|
||||||
placeholder="{% if settings.get('amazon_password') %}(gespeichert){% else %}Passwort eingeben{% endif %}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-top:1rem;">
|
|
||||||
{% if settings.get('amazon_last_sync') %}
|
|
||||||
<small class="text-muted">Letzter Abruf: {{ settings.get('amazon_last_sync') }}</small>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="form-actions" style="margin-top:1rem;">
|
|
||||||
<button type="button" class="btn btn-primary" onclick="saveAmazonSettings()">Einstellungen speichern</button>
|
|
||||||
</div>
|
|
||||||
<div id="settingsMsg" style="margin-top:0.75rem;"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>Anmeldung & Abruf</h2>
|
|
||||||
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem;">
|
|
||||||
<span>Status:</span>
|
|
||||||
<span id="sessionBadge" class="badge badge-inactive">Wird geprüft...</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- API Mode Buttons -->
|
|
||||||
<div id="apiButtons" style="display:none;">
|
|
||||||
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;align-items:center;">
|
|
||||||
<button type="button" id="btnOAuth" class="btn btn-primary" onclick="doOAuth()">Bei Amazon autorisieren</button>
|
|
||||||
<button type="button" id="btnProcessApi" class="btn btn-success" onclick="doProcess()" style="display:none;">Jetzt Rechnungen abrufen</button>
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="doReset()">Importierte zurücksetzen</button>
|
|
||||||
</div>
|
|
||||||
<!-- Manual code exchange for local setups -->
|
|
||||||
<div id="oauthCodeBox" style="display:none;margin-top:1rem;">
|
|
||||||
<p class="text-muted" style="font-size:0.85rem;">
|
|
||||||
Nach der Autorisierung bei Amazon werden Sie zu einer Seite weitergeleitet die nicht laden wird.
|
|
||||||
Kopieren Sie den Wert von <code>spapi_oauth_code</code> aus der URL-Leiste und tragen ihn hier ein:
|
|
||||||
</p>
|
|
||||||
<div style="display:flex;gap:0.5rem;align-items:center;">
|
|
||||||
<input type="text" id="oauthCode" placeholder="spapi_oauth_code hier einfuegen..."
|
|
||||||
style="flex:1;padding:0.5rem;border:1px solid var(--border-color);border-radius:4px;background:var(--input-bg);color:var(--text-color);">
|
|
||||||
<button type="button" class="btn btn-primary" onclick="exchangeCode()">Token tauschen</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Browser Mode Buttons -->
|
|
||||||
<div id="browserButtons">
|
|
||||||
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;">
|
|
||||||
<button type="button" id="btnLogin" class="btn btn-primary" onclick="doLogin()">Bei Amazon anmelden</button>
|
|
||||||
<button type="button" id="btnLogout" class="btn btn-secondary" onclick="doLogout()" style="display:none;">Session löschen</button>
|
|
||||||
<button type="button" id="btnProcess" class="btn btn-success" onclick="doProcess()" style="display:none;">Jetzt Rechnungen abrufen</button>
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="doReset()">Importierte zurücksetzen</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="processMsg" style="margin-top:0.75rem;"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Interactive Browser Modal -->
|
|
||||||
<div id="browserModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:1000;overflow:auto;">
|
|
||||||
<div style="max-width:1340px;margin:2rem auto;background:var(--card-bg);border-radius:8px;padding:1.5rem;position:relative;">
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
|
||||||
<h3 style="margin:0;">Amazon Login - Interaktiver Browser</h3>
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="closeBrowser()" style="padding:0.25rem 0.75rem;">Schließen</button>
|
|
||||||
</div>
|
|
||||||
<div id="browserInfo" class="alert alert-info" style="margin-bottom:1rem;">
|
|
||||||
Klicken und tippen Sie im Browser-Bild, um sich bei Amazon anzumelden. CAPTCHAs und 2FA können Sie direkt lösen.
|
|
||||||
</div>
|
|
||||||
<div style="position:relative;display:inline-block;border:1px solid var(--border-color);cursor:crosshair;">
|
|
||||||
<img id="browserImg" src="" alt="Browser" style="display:block;max-width:100%;height:auto;"
|
|
||||||
onclick="onBrowserClick(event)">
|
|
||||||
</div>
|
|
||||||
<div style="margin-top:1rem;display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
|
|
||||||
<input type="text" id="browserInput" placeholder="Text eingeben und Enter drücken..."
|
|
||||||
style="flex:1;min-width:200px;padding:0.5rem;border:1px solid var(--border-color);border-radius:4px;background:var(--input-bg);color:var(--text-color);"
|
|
||||||
onkeydown="onBrowserKeydown(event)">
|
|
||||||
<button type="button" class="btn btn-primary" onclick="sendBrowserText()">Senden</button>
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="sendKey('Tab')">Tab</button>
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="sendKey('Escape')">Esc</button>
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="sendKey('Backspace')">⌫</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// --- Mode Toggle ---
|
|
||||||
function toggleAmazonMode() {
|
|
||||||
const mode = document.getElementById('amazon_mode').value;
|
|
||||||
document.getElementById('apiFields').style.display = mode === 'api' ? '' : 'none';
|
|
||||||
document.getElementById('browserFields').style.display = mode === 'browser' ? '' : 'none';
|
|
||||||
document.getElementById('apiButtons').style.display = mode === 'api' ? '' : 'none';
|
|
||||||
document.getElementById('browserButtons').style.display = mode === 'browser' ? '' : 'none';
|
|
||||||
checkSession();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Settings ---
|
|
||||||
async function saveAmazonSettings() {
|
|
||||||
const btn = event.target;
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = 'Speichert...';
|
|
||||||
const msgEl = document.getElementById('settingsMsg');
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
amazon_enabled: document.getElementById('amazon_enabled').value,
|
|
||||||
amazon_mode: document.getElementById('amazon_mode').value,
|
|
||||||
amazon_domain: document.getElementById('amazon_domain').value,
|
|
||||||
amazon_email: document.getElementById('amazon_email').value,
|
|
||||||
amazon_password: document.getElementById('amazon_password').value,
|
|
||||||
amazon_since_date: document.getElementById('amazon_since_date').value,
|
|
||||||
amazon_app_id: document.getElementById('amazon_app_id').value,
|
|
||||||
amazon_client_id: document.getElementById('amazon_client_id').value,
|
|
||||||
amazon_client_secret: document.getElementById('amazon_client_secret').value,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/amazon-settings', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
const result = await resp.json();
|
|
||||||
if (result.success) {
|
|
||||||
msgEl.innerHTML = '<div class="alert alert-success">Einstellungen gespeichert</div>';
|
|
||||||
if (data.amazon_password) {
|
|
||||||
document.getElementById('amazon_password').value = '';
|
|
||||||
document.getElementById('amazon_password').placeholder = '(gespeichert)';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
msgEl.innerHTML = '<div class="alert alert-error">' + escapeHtml(result.error || 'Fehler') + '</div>';
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
msgEl.innerHTML = '<div class="alert alert-error">Verbindungsfehler</div>';
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = 'Einstellungen speichern';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Session Status ---
|
|
||||||
async function checkSession() {
|
|
||||||
const badge = document.getElementById('sessionBadge');
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/amazon-status');
|
|
||||||
const data = await resp.json();
|
|
||||||
|
|
||||||
if (data.mode === 'api') {
|
|
||||||
// API mode
|
|
||||||
if (data.api_authorized) {
|
|
||||||
badge.className = 'badge badge-success';
|
|
||||||
badge.textContent = 'API autorisiert';
|
|
||||||
document.getElementById('btnOAuth').style.display = 'none';
|
|
||||||
document.getElementById('btnProcessApi').style.display = '';
|
|
||||||
} else if (data.api_configured) {
|
|
||||||
badge.className = 'badge badge-warning';
|
|
||||||
badge.textContent = 'Nicht autorisiert';
|
|
||||||
document.getElementById('btnOAuth').style.display = '';
|
|
||||||
document.getElementById('btnProcessApi').style.display = 'none';
|
|
||||||
} else {
|
|
||||||
badge.className = 'badge badge-inactive';
|
|
||||||
badge.textContent = 'API nicht konfiguriert';
|
|
||||||
document.getElementById('btnOAuth').style.display = 'none';
|
|
||||||
document.getElementById('btnProcessApi').style.display = 'none';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Browser mode
|
|
||||||
if (data.login_active) {
|
|
||||||
badge.className = 'badge badge-warning';
|
|
||||||
badge.textContent = 'Login läuft...';
|
|
||||||
document.getElementById('btnLogout').style.display = 'none';
|
|
||||||
document.getElementById('btnProcess').style.display = 'none';
|
|
||||||
} else if (data.session_valid) {
|
|
||||||
badge.className = 'badge badge-success';
|
|
||||||
badge.textContent = 'Angemeldet';
|
|
||||||
document.getElementById('btnLogout').style.display = '';
|
|
||||||
document.getElementById('btnProcess').style.display = '';
|
|
||||||
} else {
|
|
||||||
badge.className = 'badge badge-inactive';
|
|
||||||
badge.textContent = 'Nicht angemeldet';
|
|
||||||
document.getElementById('btnLogout').style.display = 'none';
|
|
||||||
document.getElementById('btnProcess').style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
badge.className = 'badge badge-inactive';
|
|
||||||
badge.textContent = 'Unbekannt';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- OAuth Authorization (API mode) ---
|
|
||||||
async function doOAuth() {
|
|
||||||
const msgEl = document.getElementById('processMsg');
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/amazon-oauth-url');
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.url) {
|
|
||||||
window.open(data.url, '_blank');
|
|
||||||
// Show code input box for manual exchange
|
|
||||||
document.getElementById('oauthCodeBox').style.display = '';
|
|
||||||
msgEl.innerHTML = '<div class="alert alert-info">Amazon-Autorisierungsseite wurde geöffnet. Nach der Autorisierung den Code aus der URL kopieren und unten eintragen.</div>';
|
|
||||||
} else {
|
|
||||||
msgEl.innerHTML = '<div class="alert alert-error">' + escapeHtml(data.error || 'Fehler') + '</div>';
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
msgEl.innerHTML = '<div class="alert alert-error">Verbindungsfehler</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function exchangeCode() {
|
|
||||||
const codeInput = document.getElementById('oauthCode');
|
|
||||||
let code = codeInput.value.trim();
|
|
||||||
const msgEl = document.getElementById('processMsg');
|
|
||||||
|
|
||||||
if (!code) {
|
|
||||||
msgEl.innerHTML = '<div class="alert alert-error">Bitte den Code eintragen</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If user pasted the full URL, extract the code
|
|
||||||
if (code.includes('spapi_oauth_code=')) {
|
|
||||||
const url = new URL(code.startsWith('http') ? code : 'https://x.com?' + code);
|
|
||||||
code = url.searchParams.get('spapi_oauth_code') || code;
|
|
||||||
}
|
|
||||||
|
|
||||||
msgEl.innerHTML = '<div class="alert alert-info">Token wird getauscht...</div>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/amazon-oauth-exchange', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({code}),
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.success) {
|
|
||||||
msgEl.innerHTML = '<div class="alert alert-success">Erfolgreich autorisiert! Refresh-Token gespeichert.</div>';
|
|
||||||
document.getElementById('oauthCodeBox').style.display = 'none';
|
|
||||||
checkSession();
|
|
||||||
} else {
|
|
||||||
msgEl.innerHTML = '<div class="alert alert-error">' + escapeHtml(data.error || 'Token-Exchange fehlgeschlagen') + '</div>';
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
msgEl.innerHTML = '<div class="alert alert-error">Verbindungsfehler</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Interactive Browser Login ---
|
|
||||||
let screenshotInterval = null;
|
|
||||||
let loginPollInterval = null;
|
|
||||||
|
|
||||||
async function doLogin() {
|
|
||||||
const msgEl = document.getElementById('processMsg');
|
|
||||||
msgEl.innerHTML = '<div class="alert alert-info">Browser wird gestartet...</div>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetch('/api/amazon-login', {method: 'POST'});
|
|
||||||
// Open browser modal
|
|
||||||
document.getElementById('browserModal').style.display = '';
|
|
||||||
msgEl.innerHTML = '';
|
|
||||||
startScreenshotPolling();
|
|
||||||
startLoginStatePolling();
|
|
||||||
} catch (e) {
|
|
||||||
msgEl.innerHTML = '<div class="alert alert-error">Browser konnte nicht gestartet werden</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startScreenshotPolling() {
|
|
||||||
if (screenshotInterval) clearInterval(screenshotInterval);
|
|
||||||
refreshScreenshot();
|
|
||||||
screenshotInterval = setInterval(refreshScreenshot, 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshScreenshot() {
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/amazon-browser-screenshot');
|
|
||||||
if (resp.ok) {
|
|
||||||
const blob = await resp.blob();
|
|
||||||
document.getElementById('browserImg').src = URL.createObjectURL(blob);
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startLoginStatePolling() {
|
|
||||||
if (loginPollInterval) clearInterval(loginPollInterval);
|
|
||||||
loginPollInterval = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/amazon-login-state');
|
|
||||||
const data = await resp.json();
|
|
||||||
const info = document.getElementById('browserInfo');
|
|
||||||
|
|
||||||
if (data.status === 'logged_in') {
|
|
||||||
info.className = 'alert alert-success';
|
|
||||||
info.textContent = 'Erfolgreich angemeldet! Sie können das Fenster schließen.';
|
|
||||||
} else if (data.status === 'login_failed') {
|
|
||||||
info.className = 'alert alert-error';
|
|
||||||
info.textContent = data.message || 'Login fehlgeschlagen';
|
|
||||||
} else if (data.status === 'interactive') {
|
|
||||||
info.className = 'alert alert-info';
|
|
||||||
info.textContent = data.message || 'Bitte im Browser anmelden...';
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onBrowserClick(event) {
|
|
||||||
const img = event.target;
|
|
||||||
const rect = img.getBoundingClientRect();
|
|
||||||
// Scale click coordinates to actual browser viewport (1280x800)
|
|
||||||
const scaleX = 1280 / img.clientWidth;
|
|
||||||
const scaleY = 800 / img.clientHeight;
|
|
||||||
const x = Math.round((event.clientX - rect.left) * scaleX);
|
|
||||||
const y = Math.round((event.clientY - rect.top) * scaleY);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetch('/api/amazon-browser-click', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({x, y}),
|
|
||||||
});
|
|
||||||
setTimeout(refreshScreenshot, 500);
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendBrowserText() {
|
|
||||||
const input = document.getElementById('browserInput');
|
|
||||||
const text = input.value;
|
|
||||||
if (!text) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetch('/api/amazon-browser-type', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({text}),
|
|
||||||
});
|
|
||||||
input.value = '';
|
|
||||||
setTimeout(refreshScreenshot, 500);
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendKey(key) {
|
|
||||||
try {
|
|
||||||
await fetch('/api/amazon-browser-key', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({key}),
|
|
||||||
});
|
|
||||||
setTimeout(refreshScreenshot, 500);
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onBrowserKeydown(event) {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
event.preventDefault();
|
|
||||||
sendBrowserText();
|
|
||||||
// Also send Enter to the browser
|
|
||||||
sendKey('Enter');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function closeBrowser() {
|
|
||||||
if (screenshotInterval) { clearInterval(screenshotInterval); screenshotInterval = null; }
|
|
||||||
if (loginPollInterval) { clearInterval(loginPollInterval); loginPollInterval = null; }
|
|
||||||
document.getElementById('browserModal').style.display = 'none';
|
|
||||||
try {
|
|
||||||
await fetch('/api/amazon-login-close', {method: 'POST'});
|
|
||||||
} catch (e) {}
|
|
||||||
checkSession();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Logout ---
|
|
||||||
async function doLogout() {
|
|
||||||
if (!confirm('Amazon-Session wirklich löschen? Sie müssen sich danach neu anmelden.')) return;
|
|
||||||
const btn = document.getElementById('btnLogout');
|
|
||||||
btn.disabled = true;
|
|
||||||
try {
|
|
||||||
await fetch('/api/amazon-logout', {method: 'POST'});
|
|
||||||
document.getElementById('processMsg').innerHTML = '<div class="alert alert-info">Session gelöscht</div>';
|
|
||||||
} catch (e) {
|
|
||||||
document.getElementById('processMsg').innerHTML = '<div class="alert alert-error">Fehler</div>';
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
checkSession();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Process ---
|
|
||||||
async function doProcess() {
|
|
||||||
const btn = document.getElementById('btnProcess');
|
|
||||||
const msgEl = document.getElementById('processMsg');
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = 'Rechnungen werden abgerufen...';
|
|
||||||
msgEl.innerHTML = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/amazon-process', {method: 'POST'});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.error) {
|
|
||||||
msgEl.innerHTML = '<div class="alert alert-error">' + escapeHtml(data.error) + '</div>';
|
|
||||||
} else if (data.processed > 0 || data.errors > 0) {
|
|
||||||
let msg = data.processed + ' Rechnung(en) importiert';
|
|
||||||
if (data.skipped > 0) msg += ', ' + data.skipped + ' übersprungen';
|
|
||||||
if (data.errors > 0) msg += ', ' + data.errors + ' Fehler';
|
|
||||||
const cls = data.errors > 0 ? 'warning' : 'success';
|
|
||||||
msgEl.innerHTML = '<div class="alert alert-' + cls + '">' + escapeHtml(msg) + '</div>';
|
|
||||||
} else {
|
|
||||||
msgEl.innerHTML = '<div class="alert alert-info">Keine neuen Rechnungen gefunden</div>';
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
msgEl.innerHTML = '<div class="alert alert-error">Verbindungsfehler</div>';
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = 'Jetzt Rechnungen abrufen';
|
|
||||||
checkSession();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Reset ---
|
|
||||||
async function doReset() {
|
|
||||||
if (!confirm('Alle als importiert markierten Rechnungen zurücksetzen? Beim nächsten Abruf werden alle Rechnungen erneut heruntergeladen und gesendet.')) return;
|
|
||||||
const msgEl = document.getElementById('processMsg');
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/amazon-reset', {method: 'POST'});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.success) {
|
|
||||||
msgEl.innerHTML = '<div class="alert alert-info">' + data.count + ' Bestellung(en) zurückgesetzt</div>';
|
|
||||||
} else {
|
|
||||||
msgEl.innerHTML = '<div class="alert alert-error">' + escapeHtml(data.error || 'Fehler') + '</div>';
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
msgEl.innerHTML = '<div class="alert alert-error">Verbindungsfehler</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(str) {
|
|
||||||
const d = document.createElement('div');
|
|
||||||
d.textContent = str;
|
|
||||||
return d.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial setup
|
|
||||||
toggleAmazonMode();
|
|
||||||
checkSession();
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
+3
-10
@@ -5,16 +5,9 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Scan-Upload</h2>
|
<h2>Scan-Upload</h2>
|
||||||
<p class="text-muted" style="margin-bottom:1rem;">
|
<p class="text-muted" style="margin-bottom:1rem;">
|
||||||
Mehrseitige PDF hochladen. Trennseiten mit QR-Code werden automatisch erkannt und die einzelnen Dokumente gesendet.
|
Mehrseitige PDF hochladen. Trennseiten mit QR-Code werden automatisch erkannt und die einzelnen Dokumente an LexOffice gesendet.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Belegart -->
|
|
||||||
<div style="margin-bottom:1rem;display:flex;gap:1rem;align-items:center;">
|
|
||||||
<label style="margin:0;font-weight:600;">Belegart:</label>
|
|
||||||
<label style="margin:0;cursor:pointer;"><input type="radio" name="beleg_type" value="eingang" checked> Eingangsbeleg (Einkauf)</label>
|
|
||||||
<label style="margin:0;cursor:pointer;"><input type="radio" name="beleg_type" value="ausgang"> Ausgangsbeleg (Verkauf/Gutschrift)</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Upload Zone -->
|
<!-- Upload Zone -->
|
||||||
<div id="uploadZone" class="upload-zone" onclick="document.getElementById('fileInput').click()">
|
<div id="uploadZone" class="upload-zone" onclick="document.getElementById('fileInput').click()">
|
||||||
<div class="upload-icon">📄</div>
|
<div class="upload-icon">📄</div>
|
||||||
@@ -182,7 +175,7 @@ async function startProcessing(uploadId) {
|
|||||||
const resp = await fetch('/api/scan-process', {
|
const resp = await fetch('/api/scan-process', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ upload_id: uploadId, beleg_type: document.querySelector('input[name="beleg_type"]:checked').value }),
|
body: JSON.stringify({ upload_id: uploadId }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
@@ -241,7 +234,7 @@ function listenProgress(uploadId) {
|
|||||||
if (result.separator_pages > 0) {
|
if (result.separator_pages > 0) {
|
||||||
msg += ' (' + result.separator_pages + ' Trennseite(n))';
|
msg += ' (' + result.separator_pages + ' Trennseite(n))';
|
||||||
}
|
}
|
||||||
msg += ', ' + result.sent + ' gesendet';
|
msg += ', ' + result.sent + ' an LexOffice gesendet';
|
||||||
if (result.errors > 0) {
|
if (result.errors > 0) {
|
||||||
msg += ', ' + result.errors + ' Fehler';
|
msg += ', ' + result.errors + ' Fehler';
|
||||||
showResult(msg, 'warning');
|
showResult(msg, 'warning');
|
||||||
|
|||||||
+33
-625
@@ -77,14 +77,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Import - Eingangsbelege</h2>
|
<h2>LexOffice & Ordner</h2>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-group form-group-wide">
|
<div class="form-group form-group-wide">
|
||||||
<label for="import_email_eingang">Import-Email Eingangsbelege</label>
|
<label for="lexoffice_email">LexOffice Import-Emailadresse</label>
|
||||||
<input type="email" id="import_email_eingang" name="import_email_eingang"
|
<input type="email" id="lexoffice_email" name="lexoffice_email"
|
||||||
value="{{ settings.get('import_email_eingang', '') or settings.get('import_email', '') }}" placeholder="eingang@buchhaltung.example.com">
|
value="{{ settings.get('lexoffice_email', '') }}" placeholder="import-xyz@lexoffice.de">
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" id="import_email" name="import_email" value="{{ settings.get('import_email_eingang', '') or settings.get('import_email', '') }}">
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="source_folder">Eingangsordner (IMAP)</label>
|
<label for="source_folder">Eingangsordner (IMAP)</label>
|
||||||
<div class="input-with-btn">
|
<div class="input-with-btn">
|
||||||
@@ -102,37 +101,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>Import - Ausgangsbelege <small style="font-weight:normal;color:var(--text-muted);">(optional)</small></h2>
|
|
||||||
<div class="form-grid">
|
|
||||||
<div class="form-group form-group-wide">
|
|
||||||
<label for="import_email_ausgang">Import-Email Ausgangsbelege</label>
|
|
||||||
<input type="email" id="import_email_ausgang" name="import_email_ausgang"
|
|
||||||
value="{{ settings.get('import_email_ausgang', '') }}" placeholder="ausgang@buchhaltung.example.com">
|
|
||||||
<small class="text-muted">Leer lassen wenn keine Ausgangsbelege importiert werden sollen</small>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="source_folder_ausgang">Eingangsordner Ausgangsbelege (IMAP)</label>
|
|
||||||
<div class="input-with-btn">
|
|
||||||
<input type="text" id="source_folder_ausgang" name="source_folder_ausgang"
|
|
||||||
value="{{ settings.get('source_folder_ausgang', '') }}" placeholder="Ausgangsrechnungen">
|
|
||||||
<button type="button" class="btn btn-icon" onclick="openFolderPicker('source_folder_ausgang')" title="Ordner auswählen">📁</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="processed_folder_ausgang">Verarbeitet-Ordner Ausgangsbelege (IMAP)</label>
|
|
||||||
<div class="input-with-btn">
|
|
||||||
<input type="text" id="processed_folder_ausgang" name="processed_folder_ausgang"
|
|
||||||
value="{{ settings.get('processed_folder_ausgang', '') }}" placeholder="Ausgangsrechnungen/Verarbeitet">
|
|
||||||
<button type="button" class="btn btn-icon" onclick="openFolderPicker('processed_folder_ausgang')" title="Ordner auswählen">📁</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="button" class="btn btn-secondary" onclick="testEmail()">
|
<button type="button" class="btn btn-secondary" onclick="testEmail()">
|
||||||
<span class="btn-text">Test-Email senden</span>
|
<span class="btn-text">Test-Email an LexOffice senden</span>
|
||||||
<span class="btn-spinner" style="display:none;">Sende...</span>
|
<span class="btn-spinner" style="display:none;">Sende...</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -187,7 +158,7 @@
|
|||||||
value="{{ settings.get('smb_share', '') }}" placeholder="Scans">
|
value="{{ settings.get('smb_share', '') }}" placeholder="Scans">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="smb_source_path">Quellordner Eingangsbelege</label>
|
<label for="smb_source_path">Quellordner</label>
|
||||||
<div class="input-with-btn">
|
<div class="input-with-btn">
|
||||||
<input type="text" id="smb_source_path" name="smb_source_path"
|
<input type="text" id="smb_source_path" name="smb_source_path"
|
||||||
value="{{ settings.get('smb_source_path', '') }}" placeholder="(Wurzel der Freigabe)">
|
value="{{ settings.get('smb_source_path', '') }}" placeholder="(Wurzel der Freigabe)">
|
||||||
@@ -195,29 +166,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="smb_processed_path">Verarbeitet-Ordner Eingangsbelege</label>
|
<label for="smb_processed_path">Verarbeitet-Ordner</label>
|
||||||
<div class="input-with-btn">
|
<div class="input-with-btn">
|
||||||
<input type="text" id="smb_processed_path" name="smb_processed_path"
|
<input type="text" id="smb_processed_path" name="smb_processed_path"
|
||||||
value="{{ settings.get('smb_processed_path', 'Verarbeitet') }}" placeholder="Verarbeitet">
|
value="{{ settings.get('smb_processed_path', 'Verarbeitet') }}" placeholder="Verarbeitet">
|
||||||
<button type="button" class="btn btn-icon" onclick="openSmbFolderPicker('smb_processed_path')" title="Ordner auswählen">📁</button>
|
<button type="button" class="btn btn-icon" onclick="openSmbFolderPicker('smb_processed_path')" title="Ordner auswählen">📁</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label for="smb_source_path_ausgang">Quellordner Ausgangsbelege</label>
|
|
||||||
<div class="input-with-btn">
|
|
||||||
<input type="text" id="smb_source_path_ausgang" name="smb_source_path_ausgang"
|
|
||||||
value="{{ settings.get('smb_source_path_ausgang', '') }}" placeholder="(optional)">
|
|
||||||
<button type="button" class="btn btn-icon" onclick="openSmbFolderPicker('smb_source_path_ausgang')" title="Ordner auswählen">📁</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="smb_processed_path_ausgang">Verarbeitet-Ordner Ausgangsbelege</label>
|
|
||||||
<div class="input-with-btn">
|
|
||||||
<input type="text" id="smb_processed_path_ausgang" name="smb_processed_path_ausgang"
|
|
||||||
value="{{ settings.get('smb_processed_path_ausgang', '') }}" placeholder="(optional)">
|
|
||||||
<button type="button" class="btn btn-icon" onclick="openSmbFolderPicker('smb_processed_path_ausgang')" title="Ordner auswählen">📁</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="button" class="btn btn-secondary" onclick="testSmb()">
|
<button type="button" class="btn btn-secondary" onclick="testSmb()">
|
||||||
@@ -227,97 +182,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>FTP / SFTP-Server</h2>
|
|
||||||
<div class="form-grid">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="ftp_enabled">FTP-Import</label>
|
|
||||||
<select id="ftp_enabled" name="ftp_enabled">
|
|
||||||
<option value="true" {% if settings.get('ftp_enabled') == 'true' %}selected{% endif %}>Aktiviert</option>
|
|
||||||
<option value="false" {% if settings.get('ftp_enabled') != 'true' %}selected{% endif %}>Deaktiviert</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="ftp_protocol">Protokoll</label>
|
|
||||||
<select id="ftp_protocol" name="ftp_protocol" onchange="updateFtpDefaultPort()">
|
|
||||||
<option value="sftp" {% if settings.get('ftp_protocol', 'sftp') == 'sftp' %}selected{% endif %}>SFTP (SSH, verschluesselt)</option>
|
|
||||||
<option value="ftp" {% if settings.get('ftp_protocol') == 'ftp' %}selected{% endif %}>FTP (passiv, unverschluesselt)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="ftp_mode">Verarbeitungsmodus</label>
|
|
||||||
<select id="ftp_mode" name="ftp_mode">
|
|
||||||
<option value="forward" {% if settings.get('ftp_mode', 'forward') == 'forward' %}selected{% endif %}>Direkt weiterleiten</option>
|
|
||||||
<option value="separator" {% if settings.get('ftp_mode') == 'separator' %}selected{% endif %}>Trennseiten-Erkennung</option>
|
|
||||||
</select>
|
|
||||||
<small class="text-muted">Direkt: jede PDF als ein Beleg. Trennseiten: QR-Splitting wie bei Scan-Upload.</small>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="ftp_server">Server</label>
|
|
||||||
<input type="text" id="ftp_server" name="ftp_server"
|
|
||||||
value="{{ settings.get('ftp_server', '') }}" placeholder="ftp.example.com oder 192.168.1.100">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="ftp_port">Port</label>
|
|
||||||
<input type="number" id="ftp_port" name="ftp_port"
|
|
||||||
value="{{ settings.get('ftp_port', '22') }}">
|
|
||||||
<small class="text-muted">SFTP=22, FTP=21</small>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="ftp_username">Benutzername</label>
|
|
||||||
<input type="text" id="ftp_username" name="ftp_username"
|
|
||||||
value="{{ settings.get('ftp_username', '') }}" placeholder="user">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="ftp_password">Passwort</label>
|
|
||||||
<input type="password" id="ftp_password" name="ftp_password"
|
|
||||||
placeholder="{% if settings.get('ftp_password') %}(gespeichert){% else %}Passwort eingeben{% endif %}">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="ftp_source_path">Quellordner Eingangsbelege</label>
|
|
||||||
<div class="input-with-btn">
|
|
||||||
<input type="text" id="ftp_source_path" name="ftp_source_path"
|
|
||||||
value="{{ settings.get('ftp_source_path', '') }}" placeholder="(Wurzel)">
|
|
||||||
<button type="button" class="btn btn-icon" onclick="openFtpFolderPicker('ftp_source_path')" title="Ordner auswählen">📁</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="ftp_processed_path">Verarbeitet-Ordner Eingangsbelege</label>
|
|
||||||
<div class="input-with-btn">
|
|
||||||
<input type="text" id="ftp_processed_path" name="ftp_processed_path"
|
|
||||||
value="{{ settings.get('ftp_processed_path', 'Verarbeitet') }}" placeholder="Verarbeitet">
|
|
||||||
<button type="button" class="btn btn-icon" onclick="openFtpFolderPicker('ftp_processed_path')" title="Ordner auswählen">📁</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="ftp_source_path_ausgang">Quellordner Ausgangsbelege</label>
|
|
||||||
<div class="input-with-btn">
|
|
||||||
<input type="text" id="ftp_source_path_ausgang" name="ftp_source_path_ausgang"
|
|
||||||
value="{{ settings.get('ftp_source_path_ausgang', '') }}" placeholder="(optional)">
|
|
||||||
<button type="button" class="btn btn-icon" onclick="openFtpFolderPicker('ftp_source_path_ausgang')" title="Ordner auswählen">📁</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="ftp_processed_path_ausgang">Verarbeitet-Ordner Ausgangsbelege</label>
|
|
||||||
<div class="input-with-btn">
|
|
||||||
<input type="text" id="ftp_processed_path_ausgang" name="ftp_processed_path_ausgang"
|
|
||||||
value="{{ settings.get('ftp_processed_path_ausgang', '') }}" placeholder="(optional)">
|
|
||||||
<button type="button" class="btn btn-icon" onclick="openFtpFolderPicker('ftp_processed_path_ausgang')" title="Ordner auswählen">📁</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="testFtp()">
|
|
||||||
<span class="btn-text">Verbindung testen & Ordner laden</span>
|
|
||||||
<span class="btn-spinner" style="display:none;">Verbinde...</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="processFtp()">
|
|
||||||
<span class="btn-text">Jetzt abrufen</span>
|
|
||||||
<span class="btn-spinner" style="display:none;">Verarbeite...</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Zeitplan</h2>
|
<h2>Zeitplan</h2>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
@@ -342,20 +206,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>Debug</h2>
|
|
||||||
<div class="form-grid">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="debug_save_amazon_pdfs">Amazon-PDFs zwischenspeichern</label>
|
|
||||||
<select id="debug_save_amazon_pdfs" name="debug_save_amazon_pdfs">
|
|
||||||
<option value="false" {% if settings.get('debug_save_amazon_pdfs') != 'true' %}selected{% endif %}>Aus</option>
|
|
||||||
<option value="true" {% if settings.get('debug_save_amazon_pdfs') == 'true' %}selected{% endif %}>An</option>
|
|
||||||
</select>
|
|
||||||
<small class="text-muted">Speichert heruntergeladene Amazon-Rechnungen in /data/uploads/amazon_invoices/</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-actions-main">
|
<div class="form-actions-main">
|
||||||
<button type="submit" class="btn btn-primary">Einstellungen speichern</button>
|
<button type="submit" class="btn btn-primary">Einstellungen speichern</button>
|
||||||
<button type="button" class="btn btn-success" onclick="manualProcess()">
|
<button type="button" class="btn btn-success" onclick="manualProcess()">
|
||||||
@@ -378,7 +228,6 @@
|
|||||||
<th>Betreff</th>
|
<th>Betreff</th>
|
||||||
<th>Absender</th>
|
<th>Absender</th>
|
||||||
<th>Anhänge</th>
|
<th>Anhänge</th>
|
||||||
<th>Art</th>
|
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -389,13 +238,6 @@
|
|||||||
<td>{{ log.email_subject or '-' }}</td>
|
<td>{{ log.email_subject or '-' }}</td>
|
||||||
<td>{{ log.email_from or '-' }}</td>
|
<td>{{ log.email_from or '-' }}</td>
|
||||||
<td>{{ log.attachments_count }}</td>
|
<td>{{ log.attachments_count }}</td>
|
||||||
<td>
|
|
||||||
{% if log.get('beleg_type', 'eingang') == 'ausgang' %}
|
|
||||||
<span class="badge badge-warning">Ausgang</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge badge-info">Eingang</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
<td>
|
||||||
{% if log.status == 'success' %}
|
{% if log.status == 'success' %}
|
||||||
<span class="badge badge-success">OK</span>
|
<span class="badge badge-success">OK</span>
|
||||||
@@ -432,10 +274,7 @@
|
|||||||
<div class="modal">
|
<div class="modal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3 id="smbFolderModalTitle">SMB-Ordner auswählen</h3>
|
<h3 id="smbFolderModalTitle">SMB-Ordner auswählen</h3>
|
||||||
<div style="display:flex;gap:0.5rem;align-items:center;">
|
<button type="button" class="modal-close" onclick="closeSmbFolderModal()">×</button>
|
||||||
<button type="button" class="btn btn-secondary btn-sm" onclick="reloadSmbFolders()" title="Ordner-Liste neu laden">↺ Neu laden</button>
|
|
||||||
<button type="button" class="modal-close" onclick="closeSmbFolderModal()">×</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div id="smbFolderList" class="folder-list"></div>
|
<div id="smbFolderList" class="folder-list"></div>
|
||||||
@@ -447,176 +286,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="ftpFolderModal" class="modal-overlay" style="display:none;" onclick="closeFtpFolderModal(event)">
|
|
||||||
<div class="modal">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3 id="ftpFolderModalTitle">FTP-Ordner auswählen</h3>
|
|
||||||
<div style="display:flex;gap:0.5rem;align-items:center;">
|
|
||||||
<button type="button" class="btn btn-secondary btn-sm" onclick="reloadFtpFolders()" title="Ordner-Liste neu laden">↺ Neu laden</button>
|
|
||||||
<button type="button" class="modal-close" onclick="closeFtpFolderModal()">×</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div id="ftpFolderList" class="folder-list"></div>
|
|
||||||
<div id="ftpFolderLoading" class="text-muted" style="display:none;padding:1rem;">
|
|
||||||
Verbinde und lade Ordner...
|
|
||||||
</div>
|
|
||||||
<div id="ftpFolderError" class="alert alert-error" style="display:none;margin:1rem;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let cachedFolders = null;
|
let cachedFolders = null;
|
||||||
let cachedDelimiter = '.';
|
let cachedDelimiter = '.';
|
||||||
let folderTargetField = null;
|
let folderTargetField = null;
|
||||||
let createOpenFor = null;
|
let createOpenFor = null;
|
||||||
|
|
||||||
// --- Generic Tree-View Helpers (with Lazy Loading) ---
|
|
||||||
// Used by both SMB and FTP folder pickers
|
|
||||||
const expandedTreeNodes = { smb: new Set(), ftp: new Set() };
|
|
||||||
const loadedTreeNodes = { smb: new Set(['']), ftp: new Set([''])}; // root is always loaded
|
|
||||||
const loadingTreeNodes = { smb: new Set(), ftp: new Set() };
|
|
||||||
|
|
||||||
function buildFolderTree(paths) {
|
|
||||||
// Build nested tree from flat paths like ["A", "A/B", "A/B/C", "X"]
|
|
||||||
const root = { name: '', path: '', children: {} };
|
|
||||||
paths.forEach(p => {
|
|
||||||
if (!p) return;
|
|
||||||
const parts = p.split('/');
|
|
||||||
let node = root;
|
|
||||||
let curPath = '';
|
|
||||||
for (const part of parts) {
|
|
||||||
curPath = curPath ? curPath + '/' + part : part;
|
|
||||||
if (!node.children[part]) {
|
|
||||||
node.children[part] = { name: part, path: curPath, children: {} };
|
|
||||||
}
|
|
||||||
node = node.children[part];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderFolderTree(node, ns, currentValue, selectFn, addBtnFn, depth) {
|
|
||||||
let html = '';
|
|
||||||
const childKeys = Object.keys(node.children).sort((a, b) => a.localeCompare(b));
|
|
||||||
|
|
||||||
childKeys.forEach(key => {
|
|
||||||
const child = node.children[key];
|
|
||||||
const hasChildren = Object.keys(child.children).length > 0;
|
|
||||||
const isLoaded = loadedTreeNodes[ns].has(child.path);
|
|
||||||
const isLoading = loadingTreeNodes[ns].has(child.path);
|
|
||||||
const isExpanded = expandedTreeNodes[ns].has(child.path);
|
|
||||||
const isSelected = child.path === currentValue;
|
|
||||||
const escapedPath = child.path.replace(/'/g, "\\'");
|
|
||||||
|
|
||||||
// Always show toggle button - we don't know yet if there are children until loaded
|
|
||||||
const arrow = isLoading ? '⌛' // ⌛
|
|
||||||
: isExpanded ? '▼' // ▼
|
|
||||||
: '▶'; // ▶
|
|
||||||
|
|
||||||
html += '<div class="folder-row">';
|
|
||||||
html += '<button type="button" class="folder-tree-toggle" onclick="toggleTreeNode(\'' + ns + '\',\'' + escapedPath + '\')">' + arrow + '</button>';
|
|
||||||
html += '<button type="button" class="folder-item' + (isSelected ? ' selected' : '') + '" onclick="' + selectFn + '(\'' + escapedPath + '\')">';
|
|
||||||
html += '<span class="folder-icon">📁</span> ' + esc(child.name);
|
|
||||||
if (isSelected) html += ' <span class="badge badge-success">ausgewählt</span>';
|
|
||||||
html += '</button>';
|
|
||||||
html += '<button type="button" class="folder-add-btn" onclick="event.stopPropagation(); ' + addBtnFn + '(\'' + escapedPath + '\')" title="Unterordner erstellen">📁+</button>';
|
|
||||||
html += '</div>';
|
|
||||||
html += '<div id="' + ns + '-create-row-' + CSS.escape(child.path) + '" class="create-inline" style="display:none;"></div>';
|
|
||||||
|
|
||||||
if (isExpanded && (hasChildren || isLoaded)) {
|
|
||||||
html += '<div class="folder-tree-children">';
|
|
||||||
if (hasChildren) {
|
|
||||||
html += renderFolderTree(child, ns, currentValue, selectFn, addBtnFn, depth + 1);
|
|
||||||
} else if (isLoaded) {
|
|
||||||
html += '<p class="text-muted" style="padding:0.25rem 0.5rem;font-size:0.85rem;">(keine Unterordner)</p>';
|
|
||||||
}
|
|
||||||
html += '</div>';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleTreeNode(ns, path) {
|
|
||||||
if (expandedTreeNodes[ns].has(path)) {
|
|
||||||
// Collapse
|
|
||||||
expandedTreeNodes[ns].delete(path);
|
|
||||||
if (ns === 'smb') showSmbFolderModal(smbFolderTargetField);
|
|
||||||
else if (ns === 'ftp') showFtpFolderModal(ftpFolderTargetField);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expand - load subfolders if not loaded yet
|
|
||||||
if (!loadedTreeNodes[ns].has(path)) {
|
|
||||||
loadingTreeNodes[ns].add(path);
|
|
||||||
if (ns === 'smb') showSmbFolderModal(smbFolderTargetField);
|
|
||||||
else if (ns === 'ftp') showFtpFolderModal(ftpFolderTargetField);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const endpoint = '/api/list-' + ns + '-subfolders?path=' + encodeURIComponent(path);
|
|
||||||
const resp = await fetch(endpoint);
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.success && data.folders) {
|
|
||||||
// Merge new folders into cache
|
|
||||||
const cacheKey = ns === 'smb' ? 'cachedSmbFolders' : 'cachedFtpFolders';
|
|
||||||
const existingSet = new Set(window[cacheKey] || []);
|
|
||||||
data.folders.forEach(f => existingSet.add(f));
|
|
||||||
window[cacheKey] = Array.from(existingSet).sort();
|
|
||||||
loadedTreeNodes[ns].add(path);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Lazy load failed:', e);
|
|
||||||
} finally {
|
|
||||||
loadingTreeNodes[ns].delete(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expandedTreeNodes[ns].add(path);
|
|
||||||
if (ns === 'smb') showSmbFolderModal(smbFolderTargetField);
|
|
||||||
else if (ns === 'ftp') showFtpFolderModal(ftpFolderTargetField);
|
|
||||||
}
|
|
||||||
|
|
||||||
function expandTreePathsForValue(ns, value) {
|
|
||||||
if (!value) return;
|
|
||||||
const parts = value.split('/');
|
|
||||||
let cur = '';
|
|
||||||
for (let i = 0; i < parts.length - 1; i++) {
|
|
||||||
cur = cur ? cur + '/' + parts[i] : parts[i];
|
|
||||||
expandedTreeNodes[ns].add(cur);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function preloadTreePathForValue(ns, value) {
|
|
||||||
// Lazy-load all ancestor paths so the tree displays the selected value
|
|
||||||
if (!value) return;
|
|
||||||
const parts = value.split('/');
|
|
||||||
let cur = '';
|
|
||||||
const cacheKey = ns === 'smb' ? 'cachedSmbFolders' : 'cachedFtpFolders';
|
|
||||||
for (let i = 0; i < parts.length - 1; i++) {
|
|
||||||
cur = cur ? cur + '/' + parts[i] : parts[i];
|
|
||||||
if (loadedTreeNodes[ns].has(cur)) continue;
|
|
||||||
try {
|
|
||||||
const endpoint = '/api/list-' + ns + '-subfolders?path=' + encodeURIComponent(cur);
|
|
||||||
const resp = await fetch(endpoint);
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.success && data.folders) {
|
|
||||||
const existingSet = new Set(window[cacheKey] || []);
|
|
||||||
data.folders.forEach(f => existingSet.add(f));
|
|
||||||
window[cacheKey] = Array.from(existingSet).sort();
|
|
||||||
loadedTreeNodes[ns].add(cur);
|
|
||||||
}
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetTreeState(ns) {
|
|
||||||
expandedTreeNodes[ns].clear();
|
|
||||||
loadedTreeNodes[ns].clear();
|
|
||||||
loadedTreeNodes[ns].add('');
|
|
||||||
loadingTreeNodes[ns].clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
function showAlert(message, type) {
|
function showAlert(message, type) {
|
||||||
const el = document.getElementById('jsAlert');
|
const el = document.getElementById('jsAlert');
|
||||||
el.textContent = message;
|
el.textContent = message;
|
||||||
@@ -668,11 +343,8 @@ async function testEmail() {
|
|||||||
const resp = await fetch('/api/test-email', { method: 'POST', body: getFormData() });
|
const resp = await fetch('/api/test-email', { method: 'POST', body: getFormData() });
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
const eingang = document.getElementById('import_email_eingang').value;
|
const addr = document.getElementById('lexoffice_email').value;
|
||||||
const ausgang = document.getElementById('import_email_ausgang').value;
|
showAlert('Test-Email erfolgreich an ' + addr + ' gesendet! Einstellungen gespeichert.', 'success');
|
||||||
let targets = eingang;
|
|
||||||
if (ausgang) targets += ' + ' + ausgang;
|
|
||||||
showAlert('Test-Email erfolgreich an ' + targets + ' gesendet! Einstellungen gespeichert.', 'success');
|
|
||||||
} else {
|
} else {
|
||||||
showAlert('Test-Email fehlgeschlagen: ' + data.error, 'error');
|
showAlert('Test-Email fehlgeschlagen: ' + data.error, 'error');
|
||||||
}
|
}
|
||||||
@@ -775,10 +447,8 @@ function showFolderModal(targetField) {
|
|||||||
const currentValue = folderTargetField ? document.getElementById(folderTargetField).value : '';
|
const currentValue = folderTargetField ? document.getElementById(folderTargetField).value : '';
|
||||||
|
|
||||||
let html = '<div class="folder-picker-fields">';
|
let html = '<div class="folder-picker-fields">';
|
||||||
html += '<button type="button" class="folder-field-btn ' + (folderTargetField === 'source_folder' ? 'active' : '') + '" onclick="switchFolderTarget(\'source_folder\')">Eingang Quelle: <strong>' + esc(document.getElementById('source_folder').value) + '</strong></button>';
|
html += '<button type="button" class="folder-field-btn ' + (folderTargetField === 'source_folder' ? 'active' : '') + '" onclick="switchFolderTarget(\'source_folder\')">Eingangsordner: <strong>' + esc(document.getElementById('source_folder').value) + '</strong></button>';
|
||||||
html += '<button type="button" class="folder-field-btn ' + (folderTargetField === 'processed_folder' ? 'active' : '') + '" onclick="switchFolderTarget(\'processed_folder\')">Eingang Verarbeitet: <strong>' + esc(document.getElementById('processed_folder').value) + '</strong></button>';
|
html += '<button type="button" class="folder-field-btn ' + (folderTargetField === 'processed_folder' ? 'active' : '') + '" onclick="switchFolderTarget(\'processed_folder\')">Verarbeitet-Ordner: <strong>' + esc(document.getElementById('processed_folder').value) + '</strong></button>';
|
||||||
html += '<button type="button" class="folder-field-btn ' + (folderTargetField === 'source_folder_ausgang' ? 'active' : '') + '" onclick="switchFolderTarget(\'source_folder_ausgang\')">Ausgang Quelle: <strong>' + esc(document.getElementById('source_folder_ausgang').value || '(nicht gesetzt)') + '</strong></button>';
|
|
||||||
html += '<button type="button" class="folder-field-btn ' + (folderTargetField === 'processed_folder_ausgang' ? 'active' : '') + '" onclick="switchFolderTarget(\'processed_folder_ausgang\')">Ausgang Verarbeitet: <strong>' + esc(document.getElementById('processed_folder_ausgang').value || '(nicht gesetzt)') + '</strong></button>';
|
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
html += '<div class="folder-items">';
|
html += '<div class="folder-items">';
|
||||||
if (cachedFolders && cachedFolders.length > 0) {
|
if (cachedFolders && cachedFolders.length > 0) {
|
||||||
@@ -940,19 +610,12 @@ async function testSmb() {
|
|||||||
|
|
||||||
function openSmbFolderPicker(targetField) {
|
function openSmbFolderPicker(targetField) {
|
||||||
smbFolderTargetField = targetField;
|
smbFolderTargetField = targetField;
|
||||||
// Always reload when opening to ensure fresh state
|
if (cachedSmbFolders) {
|
||||||
cachedSmbFolders = null;
|
showSmbFolderModal(targetField);
|
||||||
window.cachedSmbFolders = null;
|
} else {
|
||||||
resetTreeState('smb');
|
showSmbFolderModalLoading(targetField);
|
||||||
showSmbFolderModalLoading(targetField);
|
fetchSmbFolders(targetField);
|
||||||
fetchSmbFolders(targetField);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function reloadSmbFolders() {
|
|
||||||
cachedSmbFolders = null;
|
|
||||||
resetTreeState('smb');
|
|
||||||
showSmbFolderModalLoading(smbFolderTargetField);
|
|
||||||
fetchSmbFolders(smbFolderTargetField);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchSmbFolders(targetField) {
|
async function fetchSmbFolders(targetField) {
|
||||||
@@ -961,12 +624,6 @@ async function fetchSmbFolders(targetField) {
|
|||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
cachedSmbFolders = data.folders;
|
cachedSmbFolders = data.folders;
|
||||||
window.cachedSmbFolders = data.folders;
|
|
||||||
// Preload ancestor paths if a value is already selected
|
|
||||||
if (targetField) {
|
|
||||||
const currentValue = document.getElementById(targetField).value;
|
|
||||||
await preloadTreePathForValue('smb', currentValue);
|
|
||||||
}
|
|
||||||
showSmbFolderModal(targetField);
|
showSmbFolderModal(targetField);
|
||||||
} else {
|
} else {
|
||||||
showSmbFolderModalError('SMB-Verbindung fehlgeschlagen: ' + data.error);
|
showSmbFolderModalError('SMB-Verbindung fehlgeschlagen: ' + data.error);
|
||||||
@@ -994,10 +651,8 @@ function showSmbFolderModal(targetField) {
|
|||||||
const currentValue = smbFolderTargetField ? document.getElementById(smbFolderTargetField).value : '';
|
const currentValue = smbFolderTargetField ? document.getElementById(smbFolderTargetField).value : '';
|
||||||
|
|
||||||
let html = '<div class="folder-picker-fields">';
|
let html = '<div class="folder-picker-fields">';
|
||||||
html += '<button type="button" class="folder-field-btn ' + (smbFolderTargetField === 'smb_source_path' ? 'active' : '') + '" onclick="switchSmbFolderTarget(\'smb_source_path\')">Eingang Quelle: <strong>' + esc(document.getElementById('smb_source_path').value || '(Wurzel)') + '</strong></button>';
|
html += '<button type="button" class="folder-field-btn ' + (smbFolderTargetField === 'smb_source_path' ? 'active' : '') + '" onclick="switchSmbFolderTarget(\'smb_source_path\')">Quellordner: <strong>' + esc(document.getElementById('smb_source_path').value || '(Wurzel)') + '</strong></button>';
|
||||||
html += '<button type="button" class="folder-field-btn ' + (smbFolderTargetField === 'smb_processed_path' ? 'active' : '') + '" onclick="switchSmbFolderTarget(\'smb_processed_path\')">Eingang Verarbeitet: <strong>' + esc(document.getElementById('smb_processed_path').value) + '</strong></button>';
|
html += '<button type="button" class="folder-field-btn ' + (smbFolderTargetField === 'smb_processed_path' ? 'active' : '') + '" onclick="switchSmbFolderTarget(\'smb_processed_path\')">Verarbeitet-Ordner: <strong>' + esc(document.getElementById('smb_processed_path').value) + '</strong></button>';
|
||||||
html += '<button type="button" class="folder-field-btn ' + (smbFolderTargetField === 'smb_source_path_ausgang' ? 'active' : '') + '" onclick="switchSmbFolderTarget(\'smb_source_path_ausgang\')">Ausgang Quelle: <strong>' + esc(document.getElementById('smb_source_path_ausgang').value || '(nicht gesetzt)') + '</strong></button>';
|
|
||||||
html += '<button type="button" class="folder-field-btn ' + (smbFolderTargetField === 'smb_processed_path_ausgang' ? 'active' : '') + '" onclick="switchSmbFolderTarget(\'smb_processed_path_ausgang\')">Ausgang Verarbeitet: <strong>' + esc(document.getElementById('smb_processed_path_ausgang').value || '(nicht gesetzt)') + '</strong></button>';
|
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
html += '<div class="folder-items">';
|
html += '<div class="folder-items">';
|
||||||
|
|
||||||
@@ -1012,12 +667,19 @@ function showSmbFolderModal(targetField) {
|
|||||||
html += '</div>';
|
html += '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
const smbFolders = window.cachedSmbFolders || cachedSmbFolders;
|
if (cachedSmbFolders && cachedSmbFolders.length > 0) {
|
||||||
if (smbFolders && smbFolders.length > 0) {
|
cachedSmbFolders.forEach(folder => {
|
||||||
// Auto-expand path to current value, then render tree
|
const isSelected = folder === currentValue;
|
||||||
expandTreePathsForValue('smb', currentValue);
|
const escapedFolder = folder.replace(/'/g, "\\'");
|
||||||
const tree = buildFolderTree(smbFolders);
|
html += '<div class="folder-row">';
|
||||||
html += renderFolderTree(tree, 'smb', currentValue, 'selectSmbFolder', 'toggleSmbCreateInput', 0);
|
html += '<button type="button" class="folder-item' + (isSelected ? ' selected' : '') + '" onclick="selectSmbFolder(\'' + escapedFolder + '\')">';
|
||||||
|
html += '<span class="folder-icon">📁</span> ' + esc(folder);
|
||||||
|
if (isSelected) html += ' <span class="badge badge-success">ausgewählt</span>';
|
||||||
|
html += '</button>';
|
||||||
|
html += '<button type="button" class="folder-add-btn" onclick="event.stopPropagation(); toggleSmbCreateInput(\'' + escapedFolder + '\')" title="Unterordner erstellen">📁+</button>';
|
||||||
|
html += '</div>';
|
||||||
|
html += '<div id="smb-create-row-' + CSS.escape(folder) + '" class="create-inline" style="display:none;"></div>';
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
html += '<p class="text-muted" style="padding:0.5rem;">Keine Ordner gefunden.</p>';
|
html += '<p class="text-muted" style="padding:0.5rem;">Keine Ordner gefunden.</p>';
|
||||||
}
|
}
|
||||||
@@ -1139,259 +801,5 @@ function closeSmbFolderModal(event) {
|
|||||||
document.getElementById('smbFolderModal').style.display = 'none';
|
document.getElementById('smbFolderModal').style.display = 'none';
|
||||||
cachedSmbFolders = null;
|
cachedSmbFolders = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- FTP / SFTP ---
|
|
||||||
let cachedFtpFolders = null;
|
|
||||||
let ftpFolderTargetField = null;
|
|
||||||
|
|
||||||
function updateFtpDefaultPort() {
|
|
||||||
const proto = document.getElementById('ftp_protocol').value;
|
|
||||||
const portInput = document.getElementById('ftp_port');
|
|
||||||
const current = portInput.value;
|
|
||||||
if (proto === 'sftp' && (current === '21' || !current)) {
|
|
||||||
portInput.value = '22';
|
|
||||||
} else if (proto === 'ftp' && (current === '22' || !current)) {
|
|
||||||
portInput.value = '21';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function testFtp() {
|
|
||||||
const btn = event.currentTarget;
|
|
||||||
setButtonLoading(btn, true);
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/test-ftp', { method: 'POST', body: getFormData() });
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.success) {
|
|
||||||
cachedFtpFolders = data.folders;
|
|
||||||
showAlert('FTP-Verbindung erfolgreich! Einstellungen gespeichert.', 'success');
|
|
||||||
showFtpFolderModal(null);
|
|
||||||
} else {
|
|
||||||
showAlert('FTP-Verbindung fehlgeschlagen: ' + data.error, 'error');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
showAlert('Fehler: ' + e.message, 'error');
|
|
||||||
} finally {
|
|
||||||
setButtonLoading(btn, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processFtp() {
|
|
||||||
const btn = event.currentTarget;
|
|
||||||
setButtonLoading(btn, true);
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/process-ftp', { method: 'POST', body: getFormData() });
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.error) {
|
|
||||||
showAlert('FTP-Fehler: ' + data.error, 'error');
|
|
||||||
} else {
|
|
||||||
showAlert(`FTP-Abruf fertig: ${data.processed} verarbeitet, ${data.skipped || 0} uebersprungen, ${data.errors} Fehler`, data.errors > 0 ? 'warning' : 'success');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
showAlert('Fehler: ' + e.message, 'error');
|
|
||||||
} finally {
|
|
||||||
setButtonLoading(btn, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openFtpFolderPicker(targetField) {
|
|
||||||
ftpFolderTargetField = targetField;
|
|
||||||
// Always reload when opening to ensure fresh state
|
|
||||||
cachedFtpFolders = null;
|
|
||||||
window.cachedFtpFolders = null;
|
|
||||||
resetTreeState('ftp');
|
|
||||||
showFtpFolderModalLoading(targetField);
|
|
||||||
fetchFtpFolders(targetField);
|
|
||||||
}
|
|
||||||
|
|
||||||
function reloadFtpFolders() {
|
|
||||||
cachedFtpFolders = null;
|
|
||||||
resetTreeState('ftp');
|
|
||||||
showFtpFolderModalLoading(ftpFolderTargetField);
|
|
||||||
fetchFtpFolders(ftpFolderTargetField);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchFtpFolders(targetField) {
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/test-ftp', { method: 'POST', body: getFormData() });
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.success) {
|
|
||||||
cachedFtpFolders = data.folders;
|
|
||||||
window.cachedFtpFolders = data.folders;
|
|
||||||
if (targetField) {
|
|
||||||
const currentValue = document.getElementById(targetField).value;
|
|
||||||
await preloadTreePathForValue('ftp', currentValue);
|
|
||||||
}
|
|
||||||
showFtpFolderModal(targetField);
|
|
||||||
} else {
|
|
||||||
showFtpFolderModalError('FTP-Verbindung fehlgeschlagen: ' + data.error);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
showFtpFolderModalError('Fehler: ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showFtpFolderModalLoading(targetField) {
|
|
||||||
const modal = document.getElementById('ftpFolderModal');
|
|
||||||
document.getElementById('ftpFolderList').innerHTML = '';
|
|
||||||
document.getElementById('ftpFolderLoading').style.display = '';
|
|
||||||
document.getElementById('ftpFolderError').style.display = 'none';
|
|
||||||
modal.style.display = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
function showFtpFolderModal(targetField) {
|
|
||||||
if (targetField) ftpFolderTargetField = targetField;
|
|
||||||
const modal = document.getElementById('ftpFolderModal');
|
|
||||||
document.getElementById('ftpFolderLoading').style.display = 'none';
|
|
||||||
document.getElementById('ftpFolderError').style.display = 'none';
|
|
||||||
|
|
||||||
const list = document.getElementById('ftpFolderList');
|
|
||||||
const currentValue = ftpFolderTargetField ? document.getElementById(ftpFolderTargetField).value : '';
|
|
||||||
|
|
||||||
let html = '<div class="folder-picker-fields">';
|
|
||||||
html += '<button type="button" class="folder-field-btn ' + (ftpFolderTargetField === 'ftp_source_path' ? 'active' : '') + '" onclick="switchFtpFolderTarget(\'ftp_source_path\')">Eingang Quelle: <strong>' + esc(document.getElementById('ftp_source_path').value || '(Wurzel)') + '</strong></button>';
|
|
||||||
html += '<button type="button" class="folder-field-btn ' + (ftpFolderTargetField === 'ftp_processed_path' ? 'active' : '') + '" onclick="switchFtpFolderTarget(\'ftp_processed_path\')">Eingang Verarbeitet: <strong>' + esc(document.getElementById('ftp_processed_path').value) + '</strong></button>';
|
|
||||||
html += '<button type="button" class="folder-field-btn ' + (ftpFolderTargetField === 'ftp_source_path_ausgang' ? 'active' : '') + '" onclick="switchFtpFolderTarget(\'ftp_source_path_ausgang\')">Ausgang Quelle: <strong>' + esc(document.getElementById('ftp_source_path_ausgang').value || '(nicht gesetzt)') + '</strong></button>';
|
|
||||||
html += '<button type="button" class="folder-field-btn ' + (ftpFolderTargetField === 'ftp_processed_path_ausgang' ? 'active' : '') + '" onclick="switchFtpFolderTarget(\'ftp_processed_path_ausgang\')">Ausgang Verarbeitet: <strong>' + esc(document.getElementById('ftp_processed_path_ausgang').value || '(nicht gesetzt)') + '</strong></button>';
|
|
||||||
html += '</div>';
|
|
||||||
html += '<div class="folder-items">';
|
|
||||||
|
|
||||||
// Root option for source path
|
|
||||||
if (ftpFolderTargetField === 'ftp_source_path') {
|
|
||||||
const isRoot = currentValue === '';
|
|
||||||
html += '<div class="folder-row">';
|
|
||||||
html += '<button type="button" class="folder-item' + (isRoot ? ' selected' : '') + '" onclick="selectFtpFolder(\'\')">';
|
|
||||||
html += '<span class="folder-icon">📁</span> (Wurzel des Servers)';
|
|
||||||
if (isRoot) html += ' <span class="badge badge-success">ausgewählt</span>';
|
|
||||||
html += '</button>';
|
|
||||||
html += '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
const ftpFolders = window.cachedFtpFolders || cachedFtpFolders;
|
|
||||||
if (ftpFolders && ftpFolders.length > 0) {
|
|
||||||
// Auto-expand path to current value, then render tree
|
|
||||||
expandTreePathsForValue('ftp', currentValue);
|
|
||||||
const tree = buildFolderTree(ftpFolders);
|
|
||||||
html += renderFolderTree(tree, 'ftp', currentValue, 'selectFtpFolder', 'toggleFtpCreateInput', 0);
|
|
||||||
} else {
|
|
||||||
html += '<p class="text-muted" style="padding:0.5rem;">Keine Ordner gefunden.</p>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new root folder option
|
|
||||||
html += '<div class="folder-row" style="border-top:1px solid var(--border);margin-top:0.5rem;padding-top:0.5rem;">';
|
|
||||||
html += '<button type="button" class="folder-item" onclick="event.stopPropagation(); toggleFtpCreateInput(\'\')" style="color:var(--primary);">';
|
|
||||||
html += '<span class="folder-icon">📁+</span> Neuen Ordner erstellen';
|
|
||||||
html += '</button>';
|
|
||||||
html += '</div>';
|
|
||||||
html += '<div id="ftp-create-row-root" class="create-inline" style="display:none;"></div>';
|
|
||||||
|
|
||||||
html += '</div>';
|
|
||||||
list.innerHTML = html;
|
|
||||||
modal.style.display = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
function switchFtpFolderTarget(field) {
|
|
||||||
ftpFolderTargetField = field;
|
|
||||||
showFtpFolderModal(field);
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectFtpFolder(folder) {
|
|
||||||
if (ftpFolderTargetField) {
|
|
||||||
document.getElementById(ftpFolderTargetField).value = folder;
|
|
||||||
}
|
|
||||||
showFtpFolderModal(ftpFolderTargetField);
|
|
||||||
}
|
|
||||||
|
|
||||||
function showFtpFolderModalError(msg) {
|
|
||||||
document.getElementById('ftpFolderLoading').style.display = 'none';
|
|
||||||
document.getElementById('ftpFolderError').textContent = msg;
|
|
||||||
document.getElementById('ftpFolderError').style.display = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleFtpCreateInput(parentFolder) {
|
|
||||||
document.querySelectorAll('.create-inline[id^="ftp-create-row"]').forEach(el => {
|
|
||||||
const rowId = parentFolder === '' ? 'ftp-create-row-root' : 'ftp-create-row-' + CSS.escape(parentFolder);
|
|
||||||
if (el.id !== rowId) {
|
|
||||||
el.style.display = 'none';
|
|
||||||
el.innerHTML = '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const rowId = parentFolder === '' ? 'ftp-create-row-root' : 'ftp-create-row-' + CSS.escape(parentFolder);
|
|
||||||
const row = document.getElementById(rowId);
|
|
||||||
if (!row) return;
|
|
||||||
|
|
||||||
if (row.style.display !== 'none') {
|
|
||||||
row.style.display = 'none';
|
|
||||||
row.innerHTML = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const prefix = parentFolder ? parentFolder + '/' : '';
|
|
||||||
row.innerHTML =
|
|
||||||
'<div class="create-folder-inline">' +
|
|
||||||
'<span class="create-folder-prefix">' + esc(prefix) + '</span>' +
|
|
||||||
'<input type="text" class="create-folder-input" id="newFtpSubfolderInput" placeholder="Name" autofocus>' +
|
|
||||||
'<button type="button" class="btn btn-primary btn-sm" onclick="doCreateFtpFolder(\'' + parentFolder.replace(/'/g, "\\'") + '\')">OK</button>' +
|
|
||||||
'<button type="button" class="btn btn-secondary btn-sm" onclick="toggleFtpCreateInput(\'' + parentFolder.replace(/'/g, "\\'") + '\')">Abbrechen</button>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div id="ftpCreateError" class="text-error" style="display:none;"></div>';
|
|
||||||
row.style.display = '';
|
|
||||||
|
|
||||||
const input = document.getElementById('newFtpSubfolderInput');
|
|
||||||
if (input) {
|
|
||||||
input.focus();
|
|
||||||
input.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Enter') { e.preventDefault(); doCreateFtpFolder(parentFolder); }
|
|
||||||
if (e.key === 'Escape') { toggleFtpCreateInput(parentFolder); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doCreateFtpFolder(parentFolder) {
|
|
||||||
const input = document.getElementById('newFtpSubfolderInput');
|
|
||||||
const errorEl = document.getElementById('ftpCreateError');
|
|
||||||
if (!input) return;
|
|
||||||
|
|
||||||
const subName = input.value.trim();
|
|
||||||
if (!subName) {
|
|
||||||
errorEl.textContent = 'Bitte einen Namen eingeben.';
|
|
||||||
errorEl.style.display = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fullName = parentFolder ? parentFolder + '/' + subName : subName;
|
|
||||||
errorEl.style.display = 'none';
|
|
||||||
input.disabled = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/create-ftp-folder', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ folder_name: fullName })
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.success) {
|
|
||||||
cachedFtpFolders = data.folders;
|
|
||||||
if (ftpFolderTargetField) {
|
|
||||||
document.getElementById(ftpFolderTargetField).value = fullName;
|
|
||||||
}
|
|
||||||
showFtpFolderModal(ftpFolderTargetField);
|
|
||||||
} else {
|
|
||||||
errorEl.textContent = data.error;
|
|
||||||
errorEl.style.display = '';
|
|
||||||
input.disabled = false;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
errorEl.textContent = e.message;
|
|
||||||
errorEl.style.display = '';
|
|
||||||
input.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeFtpFolderModal(event) {
|
|
||||||
if (event && event.target !== document.getElementById('ftpFolderModal')) return;
|
|
||||||
document.getElementById('ftpFolderModal').style.display = 'none';
|
|
||||||
cachedFtpFolders = null;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Binary file not shown.
+1
-3
@@ -1,7 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
belegimport:
|
belegimport:
|
||||||
build: .
|
build: .
|
||||||
container_name: belegimport
|
container_name: lexoffice-belegimport
|
||||||
ports:
|
ports:
|
||||||
- "8081:8000"
|
- "8081:8000"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -9,6 +9,4 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DB_PATH=/data/belegimport.db
|
- DB_PATH=/data/belegimport.db
|
||||||
- TZ=Europe/Berlin
|
- TZ=Europe/Berlin
|
||||||
- LOG_LEVEL=DEBUG
|
|
||||||
- OAUTH_REDIRECT_BASE=https://hacker-net.de
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -12,7 +12,3 @@ PyMuPDF==1.25.3
|
|||||||
qrcode==8.0
|
qrcode==8.0
|
||||||
sse-starlette==2.2.1
|
sse-starlette==2.2.1
|
||||||
smbprotocol==1.14.0
|
smbprotocol==1.14.0
|
||||||
paramiko==3.5.0
|
|
||||||
playwright==1.49.1
|
|
||||||
playwright-stealth==2.0.2
|
|
||||||
httpx==0.28.1
|
|
||||||
|
|||||||
Reference in New Issue
Block a user