[33e88f42] Keine Zusammenfassung angegeben.
Keine Zusammenfassung angegeben.
This commit is contained in:
@@ -1 +1 @@
|
|||||||
{"task_id": "32788f42-8544-80e1-a13a-c26114cf9b34", "token": "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", "readme_path": "readme.md", "session_start_time": "2026-04-08T16:39:28.516327"}
|
{"task_id": "33e88f42-8544-80a2-8cee-e0b11287c523", "token": "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", "readme_path": "readme.md", "session_start_time": "2026-04-10T21:51:09.122501"}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Fotograf.de Scraper & Management UI
|
# Fotograf.de Scraper & Management UI
|
||||||
|
|
||||||
**Status:** Production-Ready Microservice (Core Feature: PDF List Generation, QR Cards & Shooting Schedule)
|
**Status:** Production-Ready Microservice (Core Feature: PDF List Generation, QR Cards, Shooting Schedule & **Gmail API Integration**)
|
||||||
|
|
||||||
Dieser Service modernisiert die alten `Fotograf.de` Skripte, indem er eine robuste, web-basierte UI zur Verwaltung und Automatisierung von Foto-Aufträgen bereitstellt. Er ist als eigenständiger Microservice konzipiert, der unabhängig vom Haupt-Stack läuft.
|
Dieser Service modernisiert die alten `Fotograf.de` Skripte, indem er eine robuste, web-basierte UI zur Verwaltung und Automatisierung von Foto-Aufträgen bereitstellt. Er ist als eigenständiger Microservice konzipiert, der unabhängig vom Haupt-Stack läuft.
|
||||||
|
|
||||||
@@ -10,13 +10,13 @@ Der Service besteht aus zwei Hauptkomponenten:
|
|||||||
|
|
||||||
1. **Backend (Python / FastAPI / Selenium / SQLAlchemy):**
|
1. **Backend (Python / FastAPI / Selenium / SQLAlchemy):**
|
||||||
* **Automatisierung:** Nutzt Selenium für das Scraping von `fotograf.de`.
|
* **Automatisierung:** Nutzt Selenium für das Scraping von `fotograf.de`.
|
||||||
* **Persistenz:** Eine SQLite-Datenbank (`fotograf_jobs.db`) speichert die Auftragsliste, sodass langsame Scraping-Vorgänge nur bei Bedarf (Refresh) nötig sind.
|
* **Persistenz:** Eine SQLite-Datenbank (`fotograf_jobs.db`) speichert die Auftragsliste, sodass langsame Scraping-Vorgänge nur bei Bedarf (Refresh) nötig sind. Speichert außerdem OAuth-Tokens (`GmailToken`) für persistente E-Mail-Sitzungen.
|
||||||
* **PDF-Engine:** Nutzt WeasyPrint für Teilnehmerlisten und ReportLab/PyPDF2 für präzise PDF-Overlays (QR-Karten).
|
* **PDF-Engine:** Nutzt WeasyPrint für Teilnehmerlisten und ReportLab/PyPDF2 für präzise PDF-Overlays (QR-Karten).
|
||||||
* **API-Integration:** Direkte Anbindung an die **Calendly API (v2)** zum Abruf von Live-Buchungsdaten.
|
* **API-Integration:** Direkte Anbindung an die **Calendly API (v2)** zum Abruf von Live-Buchungsdaten sowie an die **Gmail API** für direkten E-Mail-Versand.
|
||||||
|
|
||||||
2. **Frontend (TypeScript / React / Vite / TailwindCSS):**
|
2. **Frontend (TypeScript / React / Vite / TailwindCSS):**
|
||||||
* **Modernes UI:** Ein vollständig responsives Dashboard mit Tailwind CSS (Kachel-Layout, Tabs für Kiga/Schule).
|
* **Modernes UI:** Ein vollständig responsives Dashboard mit Tailwind CSS (Kachel-Layout, Tabs für Kiga/Schule).
|
||||||
* **Arbeitsfluss:** Tools sind direkt in der Detailansicht des jeweiligen Auftrags integriert (Shooting-Planung).
|
* **Arbeitsfluss:** Tools sind direkt in der Detailansicht des jeweiligen Auftrags integriert (Shooting-Planung, E-Mail-Kampagnen).
|
||||||
|
|
||||||
## ✨ Core Features
|
## ✨ Core Features
|
||||||
|
|
||||||
@@ -36,16 +36,40 @@ Spezielles Modul für Familien-Mini-Shootings, direkt integriert in die Auftrags
|
|||||||
* Generiert eine A4-Tabelle für den Shooting-Tag im 6-Minuten-Takt.
|
* Generiert eine A4-Tabelle für den Shooting-Tag im 6-Minuten-Takt.
|
||||||
* Füllt Lücken für nicht gebuchte Slots automatisch leer auf.
|
* Füllt Lücken für nicht gebuchte Slots automatisch leer auf.
|
||||||
|
|
||||||
### Feature 3: Nachfass-E-Mails (Vollständig)
|
### Feature 3: Nachfass-E-Mails & Gmail Direkt-Versand (Vollständig)
|
||||||
* Identifizierung von Käufern/Nicht-Käufern zur Generierung von CSV-Listen für den Supermailer.
|
Identifizierung von potenziellen Käufern und automatisierter Kontakt.
|
||||||
|
* **Analyse-Logik:** Sucht nach Personen mit 0-1 Logins, die noch keine Bilder gekauft haben.
|
||||||
|
* **Supermailer Export:** Generierung einer fertigen CSV-Liste.
|
||||||
|
* **Direkter Gmail-Versand (Neu):**
|
||||||
|
* Volle OAuth 2.0 Integration. Einmaliger Login, das Refresh-Token hält die Sitzung aktiv.
|
||||||
|
* Inline-Editor für Betreff und HTML-Nachricht mit Live-Platzhaltern (`{Name Käufer}`, `{Kindernamen}`, `{LinksHTML}`).
|
||||||
|
* Massensendungs-API (Bulk Send) schickt Mails direkt über das verbundene Postfach.
|
||||||
|
|
||||||
### Feature 4: Verkaufs-Statistiken (Vollständig)
|
### Feature 4: Verkaufs-Statistiken (Vollständig)
|
||||||
* Detaillierte Analyse des Kaufverhaltens pro Album mit Echtzeit-Fortschrittsanzeige im Browser.
|
* Detaillierte Analyse des Kaufverhaltens pro Album mit Echtzeit-Fortschrittsanzeige im Browser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Nächste Session: "Freigabeanfragen" (Feature 5)
|
||||||
|
|
||||||
|
Das nächste große Ziel ist der automatische Versand von Freigabeanfragen via Gmail.
|
||||||
|
|
||||||
|
**Der geplante Workflow (Bestätigt):**
|
||||||
|
Anstatt Bild für Bild auf `fotograf.de` zu prüfen, nutzen wir die bereits erteilte **Einwilligung aus Calendly**.
|
||||||
|
* **Logik:** Das System prüft die Calendly-Buchungen des Shootings auf die Frage *"Dürfen wir einige schöne Aufnahmen veröffentlichen?"*.
|
||||||
|
* Alle Kunden, die hier "Ja" (bzw. eine positive Antwort) angegeben haben, werden extrahiert und erhalten automatisiert über die Gmail-API die Freigabeanfrage.
|
||||||
|
* *Text & Template werden in der nächsten Sitzung definiert.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🛠️ Technische Details & Fixes (April 2026)
|
## 🛠️ Technische Details & Fixes (April 2026)
|
||||||
|
* **E-Mail Signatur:** Die offizielle HTML-Signatur von "Kinderfotos Erding" (inkl. Logo via Google Drive Link) ist hartcodiert hinterlegt und wird bei jedem Massen- oder Testversand unsichtbar im Hintergrund an den HTML-Body angehängt.
|
||||||
|
* **Gmail OAuth Architektur:** Strikt getrennt. App-Credentials (`client_id`, `client_secret`) liegen in der `.env`. User-Credentials (Access & Refresh Tokens) werden dynamisch in der SQLite-Datenbank (`GmailToken`) gespeichert.
|
||||||
|
* **Routing / Reverse Proxy:**
|
||||||
|
* Das Frontend (React/Vite) nutzt den `base: '/fotograf-de/'` Pfad, um Assets korrekt hinter dem Nginx-Proxy zu laden.
|
||||||
|
* API-Aufrufe nutzen relative Pfade (`/fotograf-de-api/`), welche von Nginx sauber zum internen Backend-Port `8000` umgeschrieben (`rewrite`) werden. Dies verhindert Mixed-Content und CORS-Fehler im Produktionsbetrieb.
|
||||||
* **Zeitzonen:** Automatische Konversion von Calendly-UTC-Zeiten in die lokale Zeit (`Europe/Berlin`).
|
* **Zeitzonen:** Automatische Konversion von Calendly-UTC-Zeiten in die lokale Zeit (`Europe/Berlin`).
|
||||||
* **Pagination Fix:** Das Backend blättert durch alle Calendly-Seiten für lückenlose Daten.
|
* **Pagination Fix:** Das Backend blättert durch alle Calendly-Seiten für lückenlose Daten.
|
||||||
* **Logo-Integration:** Dynamisches Einbetten des Firmenlogos in alle generierten Dokumente.
|
|
||||||
|
|
||||||
## 🚀 Deployment & Konfiguration
|
## 🚀 Deployment & Konfiguration
|
||||||
|
|
||||||
@@ -55,8 +79,9 @@ Der Service wird über eine eigene `docker-compose.yml` im Unterverzeichnis gest
|
|||||||
Folgende Variablen müssen in der `.env` im Verzeichnis `/fotograf-de-scraper/` definiert sein:
|
Folgende Variablen müssen in der `.env` im Verzeichnis `/fotograf-de-scraper/` definiert sein:
|
||||||
* `KIGA_USER` / `KIGA_PW` / `SCHULE_USER` / `SCHULE_PW`: Logins für Fotograf.de.
|
* `KIGA_USER` / `KIGA_PW` / `SCHULE_USER` / `SCHULE_PW`: Logins für Fotograf.de.
|
||||||
* `CALENDLY_TOKEN`: Personal Access Token (JWT) von Calendly.
|
* `CALENDLY_TOKEN`: Personal Access Token (JWT) von Calendly.
|
||||||
|
* `google_fotograf_client_id` / `google_fotograf_secret`: Die OAuth-App-Credentials aus der Google Cloud Console.
|
||||||
|
* `GOOGLE_REDIRECT_URI`: (Optional) Standard ist `https://floke-ai.duckdns.org/fotograf-de-api/api/auth/callback`.
|
||||||
|
|
||||||
### URLs & Ports
|
### URLs & Ports
|
||||||
* **Scraper Frontend:** `http://192.168.178.6:3009`
|
* **Produktion / Nginx:** `https://floke-ai.duckdns.org/fotograf-de/`
|
||||||
* **Zentrales Dashboard:** `http://192.168.178.6:8090`
|
|
||||||
* **Persistenz:** Datenbank unter `./backend/data/fotograf_jobs.db`.
|
* **Persistenz:** Datenbank unter `./backend/data/fotograf_jobs.db`.
|
||||||
@@ -22,6 +22,12 @@ class Job(Base):
|
|||||||
account_type = Column(String, index=True) # 'kiga' or 'schule'
|
account_type = Column(String, index=True) # 'kiga' or 'schule'
|
||||||
last_updated = Column(DateTime, default=datetime.datetime.utcnow)
|
last_updated = Column(DateTime, default=datetime.datetime.utcnow)
|
||||||
|
|
||||||
|
class GmailToken(Base):
|
||||||
|
__tablename__ = "gmail_tokens"
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
token_json = Column(String) # Stores the full credentials JSON
|
||||||
|
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
|
||||||
|
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
|
|||||||
129
fotograf-de-scraper/backend/gmail_service.py
Normal file
129
fotograf-de-scraper/backend/gmail_service.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import datetime
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from google.oauth2.credentials import Credentials
|
||||||
|
from google_auth_oauthlib.flow import Flow
|
||||||
|
from googleapiclient.discovery import build
|
||||||
|
from google.auth.transport.requests import Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from database import GmailToken
|
||||||
|
import base64
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
|
logger = logging.getLogger("gmail-service")
|
||||||
|
|
||||||
|
# Scopes required for sending emails
|
||||||
|
SCOPES = ['https://www.googleapis.com/auth/gmail.send']
|
||||||
|
|
||||||
|
class GmailService:
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
self.client_id = os.getenv("google_fotograf_client_id")
|
||||||
|
self.client_secret = os.getenv("google_fotograf_secret")
|
||||||
|
|
||||||
|
# Redirect URI - must match what was configured in Google Console
|
||||||
|
# We try to detect the public URL, fallback to duckdns
|
||||||
|
self.redirect_uri = os.getenv("GOOGLE_REDIRECT_URI", "https://floke-ai.duckdns.org/fotograf-de-api/api/auth/callback")
|
||||||
|
|
||||||
|
def _get_client_config(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"web": {
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"project_id": "fotograf-tool",
|
||||||
|
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||||
|
"token_uri": "https://oauth2.googleapis.com/token",
|
||||||
|
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
"redirect_uris": [self.redirect_uri]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_auth_url(self) -> str:
|
||||||
|
flow = Flow.from_client_config(
|
||||||
|
self._get_client_config(),
|
||||||
|
scopes=SCOPES,
|
||||||
|
redirect_uri=self.redirect_uri
|
||||||
|
)
|
||||||
|
auth_url, _ = flow.authorization_url(prompt='consent', access_type='offline')
|
||||||
|
return auth_url
|
||||||
|
|
||||||
|
def handle_callback(self, code: str):
|
||||||
|
flow = Flow.from_client_config(
|
||||||
|
self._get_client_config(),
|
||||||
|
scopes=SCOPES,
|
||||||
|
redirect_uri=self.redirect_uri
|
||||||
|
)
|
||||||
|
flow.fetch_token(code=code)
|
||||||
|
credentials = flow.credentials
|
||||||
|
self._save_token(credentials)
|
||||||
|
return credentials
|
||||||
|
|
||||||
|
def _save_token(self, credentials):
|
||||||
|
token_data = {
|
||||||
|
'token': credentials.token,
|
||||||
|
'refresh_token': credentials.refresh_token,
|
||||||
|
'token_uri': credentials.token_uri,
|
||||||
|
'client_id': credentials.client_id,
|
||||||
|
'client_secret': credentials.client_secret,
|
||||||
|
'scopes': credentials.scopes
|
||||||
|
}
|
||||||
|
|
||||||
|
db_token = self.db.query(GmailToken).first()
|
||||||
|
if not db_token:
|
||||||
|
db_token = GmailToken(token_json=json.dumps(token_data))
|
||||||
|
self.db.add(db_token)
|
||||||
|
else:
|
||||||
|
db_token.token_json = json.dumps(token_data)
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
logger.info("Gmail OAuth token saved to database.")
|
||||||
|
|
||||||
|
def get_credentials(self) -> Optional[Credentials]:
|
||||||
|
db_token = self.db.query(GmailToken).first()
|
||||||
|
if not db_token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
token_data = json.loads(db_token.token_json)
|
||||||
|
creds = Credentials.from_authorized_user_info(token_data, SCOPES)
|
||||||
|
|
||||||
|
if creds and creds.expired and creds.refresh_token:
|
||||||
|
logger.info("Gmail token expired, refreshing...")
|
||||||
|
creds.refresh(Request())
|
||||||
|
self._save_token(creds)
|
||||||
|
|
||||||
|
return creds
|
||||||
|
|
||||||
|
def is_authenticated(self) -> bool:
|
||||||
|
try:
|
||||||
|
creds = self.get_credentials()
|
||||||
|
return creds is not None and creds.valid
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Auth check failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_email(self, to: str, subject: str, body_html: str) -> bool:
|
||||||
|
creds = self.get_credentials()
|
||||||
|
if not creds:
|
||||||
|
logger.error("Cannot send email: Not authenticated.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
service = build('gmail', 'v1', credentials=creds)
|
||||||
|
message = MIMEText(body_html, 'html')
|
||||||
|
message['to'] = to
|
||||||
|
message['subject'] = subject
|
||||||
|
|
||||||
|
raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode()
|
||||||
|
|
||||||
|
send_result = service.users().messages().send(
|
||||||
|
userId='me',
|
||||||
|
body={'raw': raw_message}
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
logger.info(f"Email sent to {to}. Message ID: {send_result['id']}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send email to {to}: {e}")
|
||||||
|
return False
|
||||||
@@ -746,14 +746,16 @@ def process_reminder_analysis(task_id: str, job_id: str, account_type: str):
|
|||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Depends, BackgroundTasks, UploadFile, File, Form
|
from fastapi import FastAPI, HTTPException, Depends, BackgroundTasks, UploadFile, File, Form
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from database import get_db, Job as DBJob, engine, Base
|
from database import get_db, Job as DBJob, engine, Base
|
||||||
import math
|
import math
|
||||||
import uuid
|
import uuid
|
||||||
from qr_generator import get_calendly_events, overlay_text_on_pdf, get_calendly_event_types
|
from qr_generator import get_calendly_events, overlay_text_on_pdf, get_calendly_event_types
|
||||||
|
from gmail_service import GmailService
|
||||||
|
|
||||||
# --- API Endpoints ---
|
# --- API Endpoints ---
|
||||||
|
|
||||||
@@ -914,6 +916,31 @@ async def download_latest_file():
|
|||||||
async def health_check():
|
async def health_check():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
# --- Gmail API Endpoints ---
|
||||||
|
|
||||||
|
@app.get("/api/auth/google")
|
||||||
|
async def get_google_auth_url(db: Session = Depends(get_db)):
|
||||||
|
service = GmailService(db)
|
||||||
|
return {"url": service.get_auth_url()}
|
||||||
|
|
||||||
|
@app.get("/api/auth/callback")
|
||||||
|
async def google_auth_callback(code: str, db: Session = Depends(get_db)):
|
||||||
|
service = GmailService(db)
|
||||||
|
try:
|
||||||
|
service.handle_callback(code)
|
||||||
|
# Redirect back to frontend
|
||||||
|
# The frontend lives at /fotograf-de/ through NGINX
|
||||||
|
frontend_url = os.getenv("FRONTEND_URL", "https://floke-ai.duckdns.org/fotograf-de/")
|
||||||
|
return RedirectResponse(url=frontend_url)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Auth callback failed: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"message": f"Authentifizierung fehlgeschlagen: {str(e)}"})
|
||||||
|
|
||||||
|
@app.get("/api/gmail/status")
|
||||||
|
async def get_gmail_status(db: Session = Depends(get_db)):
|
||||||
|
service = GmailService(db)
|
||||||
|
return {"authenticated": service.is_authenticated()}
|
||||||
|
|
||||||
@app.get("/api/jobs", response_model=List[Dict[str, Any]])
|
@app.get("/api/jobs", response_model=List[Dict[str, Any]])
|
||||||
async def get_jobs(account_type: str, force_refresh: bool = False, db: Session = Depends(get_db)):
|
async def get_jobs(account_type: str, force_refresh: bool = False, db: Session = Depends(get_db)):
|
||||||
logger.info(f"API Request: GET /api/jobs for {account_type} (force_refresh={force_refresh})")
|
logger.info(f"API Request: GET /api/jobs for {account_type} (force_refresh={force_refresh})")
|
||||||
@@ -1034,6 +1061,34 @@ async def download_task_csv(task_id: str):
|
|||||||
logger.error(f"Export error: {e}")
|
logger.error(f"Export error: {e}")
|
||||||
raise HTTPException(status_code=500, detail="CSV Export fehlgeschlagen.")
|
raise HTTPException(status_code=500, detail="CSV Export fehlgeschlagen.")
|
||||||
|
|
||||||
|
class BulkEmailRequest(BaseModel):
|
||||||
|
emails: List[Dict[str, str]]
|
||||||
|
|
||||||
|
@app.post("/api/gmail/send-bulk")
|
||||||
|
async def send_bulk_emails(request: BulkEmailRequest, db: Session = Depends(get_db)):
|
||||||
|
service = GmailService(db)
|
||||||
|
if not service.is_authenticated():
|
||||||
|
raise HTTPException(status_code=401, detail="Gmail nicht authentifiziert.")
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
failed_emails = []
|
||||||
|
|
||||||
|
for email_data in request.emails:
|
||||||
|
to = email_data.get("to")
|
||||||
|
subject = email_data.get("subject")
|
||||||
|
body = email_data.get("body")
|
||||||
|
|
||||||
|
if service.send_email(to, subject, body):
|
||||||
|
success_count += 1
|
||||||
|
else:
|
||||||
|
failed_emails.append(to)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": len(request.emails),
|
||||||
|
"success": success_count,
|
||||||
|
"failed": failed_emails
|
||||||
|
}
|
||||||
|
|
||||||
@app.get("/api/jobs/{job_id}/generate-pdf")
|
@app.get("/api/jobs/{job_id}/generate-pdf")
|
||||||
async def generate_pdf(job_id: str, account_type: str, db: Session = Depends(get_db)):
|
async def generate_pdf(job_id: str, account_type: str, db: Session = Depends(get_db)):
|
||||||
logger.info(f"API Request: Generate PDF for job {job_id} ({account_type})")
|
logger.info(f"API Request: Generate PDF for job {job_id} ({account_type})")
|
||||||
|
|||||||
@@ -12,3 +12,6 @@ requests==2.31.0
|
|||||||
reportlab==4.0.9
|
reportlab==4.0.9
|
||||||
PyPDF2==3.0.1
|
PyPDF2==3.0.1
|
||||||
tzdata
|
tzdata
|
||||||
|
google-api-python-client==2.122.0
|
||||||
|
google-auth-httplib2==0.2.0
|
||||||
|
google-auth-oauthlib==1.2.0
|
||||||
|
|||||||
@@ -42,8 +42,64 @@ function App() {
|
|||||||
const [reminderProgress, setReminderProgress] = useState<string>('');
|
const [reminderProgress, setReminderProgress] = useState<string>('');
|
||||||
const [isReminderRunning, setIsReminderRunning] = useState(false);
|
const [isReminderRunning, setIsReminderRunning] = useState(false);
|
||||||
const [latestFile, setLatestFile] = useState<any>(null);
|
const [latestFile, setLatestFile] = useState<any>(null);
|
||||||
|
const [isGmailAuthenticated, setIsGmailAuthenticated] = useState(false);
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://192.168.178.6:8002';
|
// Email States
|
||||||
|
const [reminderResult, setReminderResult] = useState<any[] | null>(null);
|
||||||
|
const [emailSubject, setEmailSubject] = useState("Fotos von {Kindernamen}");
|
||||||
|
const [emailBody, setEmailBody] = useState("Hallo {Name Käufer},<br><br>deine Fotos sind fertig und warten auf dich! Klicke einfach auf die Links unten, um direkt zu den Galerien zu gelangen:<br><br>{LinksHTML}<br><br>Viel Spaß beim Anschauen!");
|
||||||
|
const [isSendingEmails, setIsSendingEmails] = useState(false);
|
||||||
|
const [emailSendStatus, setEmailSendStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Email Signature (Cleaned up from user input)
|
||||||
|
const SIGNATURE_HTML = `
|
||||||
|
<br><br>
|
||||||
|
<span style="color: #888;">--</span><br>
|
||||||
|
<div dir="ltr">
|
||||||
|
<table border="0" cellspacing="0" cellpadding="0" style="border-collapse:collapse; margin-top: 5px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td width="220" valign="top" style="padding-right: 15px;">
|
||||||
|
<img width="200" src="https://lh3.googleusercontent.com/d/1K7RODOqKE2e1nRJ3D4dEWdjthoTMyXUq" alt="Kinderfotos Erding Logo" style="display: block;">
|
||||||
|
</td>
|
||||||
|
<td valign="bottom" style="padding-left: 15px; border-left: 1px solid #ddd; font-family: sans-serif; font-size: 13px; color: #333; line-height: 1.5;">
|
||||||
|
<p style="margin: 0;"><b>Kinderfotos Erding</b> | <a href="http://www.kinderfotos-erding.de/" target="_blank" style="color: #1155cc; text-decoration: none;">www.kinderfotos-erding.de</a></p>
|
||||||
|
<p style="margin: 0; color: #666;">Gartenstr. 10 | 85445 Oberding | 08122-8470867</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// If we are on duckdns, use the relative path, otherwise use local IP
|
||||||
|
const API_BASE_URL = window.location.hostname.includes('duckdns.org')
|
||||||
|
? '/fotograf-de-api'
|
||||||
|
: (import.meta.env.VITE_API_BASE_URL || 'http://192.168.178.6:8002');
|
||||||
|
|
||||||
|
const checkGmailAuth = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/gmail/status`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setIsGmailAuthenticated(data.authenticated);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to check Gmail auth status");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGmailLogin = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/auth/google`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
window.location.href = data.url;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError("Konnte Gmail-Anmeldung nicht starten.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchLatestFile = async () => {
|
const fetchLatestFile = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -83,6 +139,7 @@ function App() {
|
|||||||
fetchJobs(activeTab, false);
|
fetchJobs(activeTab, false);
|
||||||
}
|
}
|
||||||
fetchLatestFile();
|
fetchLatestFile();
|
||||||
|
checkGmailAuth();
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
const handleRefresh = () => fetchJobs(activeTab, true);
|
const handleRefresh = () => fetchJobs(activeTab, true);
|
||||||
@@ -157,6 +214,18 @@ function App() {
|
|||||||
};
|
};
|
||||||
}, [statsTaskId, isStatsRunning]);
|
}, [statsTaskId, isStatsRunning]);
|
||||||
|
|
||||||
|
const handleFetchReminderResult = async (taskId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/tasks/${taskId}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setReminderResult(data.result);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch reminder results");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let interval: any;
|
let interval: any;
|
||||||
if (reminderTaskId && isReminderRunning) {
|
if (reminderTaskId && isReminderRunning) {
|
||||||
@@ -168,8 +237,7 @@ function App() {
|
|||||||
setReminderProgress(data.progress || 'Verarbeite...');
|
setReminderProgress(data.progress || 'Verarbeite...');
|
||||||
if (data.status === 'completed') {
|
if (data.status === 'completed') {
|
||||||
setIsReminderRunning(false);
|
setIsReminderRunning(false);
|
||||||
// Auto-trigger download or show button? The user wants a CSV.
|
handleFetchReminderResult(reminderTaskId);
|
||||||
// Let's keep the task ID so we can show a download button.
|
|
||||||
} else if (data.status === 'error') {
|
} else if (data.status === 'error') {
|
||||||
setError(data.progress || 'Ein Fehler ist aufgetreten.');
|
setError(data.progress || 'Ein Fehler ist aufgetreten.');
|
||||||
setIsReminderRunning(false);
|
setIsReminderRunning(false);
|
||||||
@@ -296,6 +364,78 @@ function App() {
|
|||||||
setError("Download fehlgeschlagen.");
|
setError("Download fehlgeschlagen.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSendEmails = async () => {
|
||||||
|
if (!reminderResult || !isGmailAuthenticated) return;
|
||||||
|
|
||||||
|
setIsSendingEmails(true);
|
||||||
|
setEmailSendStatus("Sende...");
|
||||||
|
|
||||||
|
const emailsToSend = reminderResult.map(row => {
|
||||||
|
let subject = emailSubject.replace(/{Kindernamen}/g, row["Kindernamen"]);
|
||||||
|
let body = emailBody
|
||||||
|
.replace(/{Name Käufer}/g, row["Name Käufer"])
|
||||||
|
.replace(/{Kindernamen}/g, row["Kindernamen"])
|
||||||
|
.replace(/{LinksHTML}/g, row["LinksHTML"])
|
||||||
|
.replace(/\n/g, "<br>");
|
||||||
|
|
||||||
|
return {
|
||||||
|
to: row["E-Mail-Adresse Käufer"],
|
||||||
|
subject: subject,
|
||||||
|
body: body + SIGNATURE_HTML
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/gmail/send-bulk`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ emails: emailsToSend })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setEmailSendStatus(`✅ Fertig! ${data.success} gesendet.`);
|
||||||
|
if (data.failed.length > 0) {
|
||||||
|
setEmailSendStatus(prev => `${prev} (${data.failed.length} Fehler)`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error("Sende-Fehler");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setEmailSendStatus("❌ Fehler beim Senden");
|
||||||
|
} finally {
|
||||||
|
setIsSendingEmails(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendTestEmail = async () => {
|
||||||
|
if (!isGmailAuthenticated) return;
|
||||||
|
setIsSendingEmails(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/gmail/send-bulk`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
emails: [{
|
||||||
|
to: "floke.com@gmail.com",
|
||||||
|
subject: "Test-E-Mail vom Fotograf Tool (inkl. Signatur)",
|
||||||
|
body: "Hallo! Das ist eine Test-E-Mail, um die Gmail-API-Integration zu verifizieren. Wenn du das liest, funktioniert alles perfekt! 🚀" + SIGNATURE_HTML
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
alert("Test-E-Mail erfolgreich an floke.com@gmail.com gesendet!");
|
||||||
|
} else {
|
||||||
|
alert("Fehler beim Senden der Test-E-Mail.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert("Netzwerkfehler beim Senden der Test-E-Mail.");
|
||||||
|
} finally {
|
||||||
|
setIsSendingEmails(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const currentJobs = jobsCache[activeTab];
|
const currentJobs = jobsCache[activeTab];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -317,6 +457,28 @@ function App() {
|
|||||||
Zum Dashboard
|
Zum Dashboard
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleGmailLogin}
|
||||||
|
className={`flex text-xs font-medium transition-colors items-center gap-1 px-2 py-1 rounded border ${
|
||||||
|
isGmailAuthenticated
|
||||||
|
? 'bg-emerald-50 text-emerald-600 border-emerald-200'
|
||||||
|
: 'bg-amber-50 text-amber-600 border-amber-200 hover:bg-amber-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-lg">{isGmailAuthenticated ? '✅' : '✉️'}</span>
|
||||||
|
{isGmailAuthenticated ? 'Gmail verbunden' : 'Gmail verbinden'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isGmailAuthenticated && (
|
||||||
|
<button
|
||||||
|
onClick={handleSendTestEmail}
|
||||||
|
disabled={isSendingEmails}
|
||||||
|
className="text-[10px] bg-white border border-gray-200 text-gray-400 hover:text-indigo-600 hover:border-indigo-200 px-2 py-1 rounded transition-all"
|
||||||
|
>
|
||||||
|
{isSendingEmails ? 'Sende...' : 'Test-Mail senden'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{latestFile && (
|
{latestFile && (
|
||||||
<div className="hidden lg:flex items-center gap-2">
|
<div className="hidden lg:flex items-center gap-2">
|
||||||
<span className="text-xs text-gray-400">Letzte Datei:</span>
|
<span className="text-xs text-gray-400">Letzte Datei:</span>
|
||||||
@@ -623,13 +785,70 @@ function App() {
|
|||||||
<p className="text-xs break-words">{reminderProgress}</p>
|
<p className="text-xs break-words">{reminderProgress}</p>
|
||||||
</div>
|
</div>
|
||||||
) : reminderTaskId ? (
|
) : reminderTaskId ? (
|
||||||
<button
|
<div className="space-y-3">
|
||||||
onClick={() => handleDownloadReminderCsv(reminderTaskId)}
|
<button
|
||||||
className="w-full px-4 py-2 bg-emerald-600 text-white text-sm font-medium rounded-lg hover:bg-emerald-700 transition-colors shadow-sm flex items-center justify-center gap-2"
|
onClick={() => handleDownloadReminderCsv(reminderTaskId)}
|
||||||
>
|
className="w-full px-4 py-2 bg-emerald-600 text-white text-sm font-medium rounded-lg hover:bg-emerald-700 transition-colors shadow-sm flex items-center justify-center gap-2"
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a2 2 0 002 2h12a2 2 0 002-2v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
|
>
|
||||||
CSV herunterladen
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a2 2 0 002 2h12a2 2 0 002-2v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
|
||||||
</button>
|
CSV für Supermailer
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{reminderResult && isGmailAuthenticated && (
|
||||||
|
<div className="mt-4 border-t border-gray-100 pt-4 bg-gray-50 p-4 rounded-xl space-y-4">
|
||||||
|
<h6 className="font-bold text-gray-900 flex items-center gap-2">
|
||||||
|
<span>🚀</span> Gmail Direkt-Versand
|
||||||
|
</h6>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{reminderResult.length} Empfänger identifiziert.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-bold text-gray-400 uppercase">Betreff</label>
|
||||||
|
<input
|
||||||
|
value={emailSubject}
|
||||||
|
onChange={(e) => setEmailSubject(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-bold text-gray-400 uppercase">Nachricht (HTML erlaubt)</label>
|
||||||
|
<textarea
|
||||||
|
value={emailBody}
|
||||||
|
onChange={(e) => setEmailBody(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 font-mono"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between items-center text-[10px] text-gray-400">
|
||||||
|
<span>Platzhalter: {"{Name Käufer}"}, {"{Kindernamen}"}, {"{LinksHTML}"}</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<svg className="w-3 h-3 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||||
|
Signatur "Kinderfotos Erding" wird automatisch angehängt
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSendEmails}
|
||||||
|
disabled={isSendingEmails}
|
||||||
|
className="w-full py-2.5 bg-indigo-600 text-white text-sm font-bold rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-all shadow-md flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isSendingEmails ? (
|
||||||
|
<>
|
||||||
|
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" /></svg>
|
||||||
|
Sende Mails...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>Mails jetzt versenden</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{emailSendStatus && (
|
||||||
|
<p className="text-center text-xs font-bold text-indigo-600">{emailSendStatus}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleStartReminderAnalysis(selectedJob)}
|
onClick={() => handleStartReminderAnalysis(selectedJob)}
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ import react from '@vitejs/plugin-react'
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
base: '/fotograf-de/', // Ensures assets are loaded with the correct prefix behind NGINX
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user