[32788f42] Implement Feature 3: Nachfass-E-Mails (Reminder Analysis) with CSV export for Supermailer
This commit is contained in:
@@ -76,6 +76,9 @@ SELECTORS = {
|
|||||||
"person_all_photos": ".//div[@data-key]",
|
"person_all_photos": ".//div[@data-key]",
|
||||||
"person_purchased_photos": ".//div[@data-key and .//img[@alt='Bestellungen mit diesem Foto']]",
|
"person_purchased_photos": ".//div[@data-key and .//img[@alt='Bestellungen mit diesem Foto']]",
|
||||||
"person_access_card_photo": ".//div[@data-key and contains(@class, 'opacity-50')]",
|
"person_access_card_photo": ".//div[@data-key and contains(@class, 'opacity-50')]",
|
||||||
|
"potential_buyer_link": "//a[contains(@href, '/config_customers/view_customer')]",
|
||||||
|
"quick_login_url": "//a[@id='quick-login-url']",
|
||||||
|
"buyer_email": "//span[contains(., '@')]",
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- PDF Generation Logic ---
|
# --- PDF Generation Logic ---
|
||||||
@@ -538,6 +541,162 @@ def process_statistics(task_id: str, job_id: str, account_type: str):
|
|||||||
logger.debug(f"Task {task_id}: Closing driver.")
|
logger.debug(f"Task {task_id}: Closing driver.")
|
||||||
driver.quit()
|
driver.quit()
|
||||||
|
|
||||||
|
def process_reminder_analysis(task_id: str, job_id: str, account_type: str):
|
||||||
|
logger.info(f"Task {task_id}: Starting reminder analysis for job {job_id}")
|
||||||
|
task_store[task_id] = {"status": "running", "progress": "Initialisiere Browser...", "result": None}
|
||||||
|
|
||||||
|
username = os.getenv(f"{account_type.upper()}_USER")
|
||||||
|
password = os.getenv(f"{account_type.upper()}_PW")
|
||||||
|
driver = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
driver = setup_driver()
|
||||||
|
if not driver or not login(driver, username, password):
|
||||||
|
task_store[task_id] = {"status": "error", "progress": "Login fehlgeschlagen."}
|
||||||
|
return
|
||||||
|
|
||||||
|
wait = WebDriverWait(driver, 15)
|
||||||
|
|
||||||
|
# 1. Navigate to albums overview
|
||||||
|
albums_overview_url = f"https://app.fotograf.de/config_jobs_photos/index/{job_id}"
|
||||||
|
task_store[task_id]["progress"] = "Lade Alben-Übersicht..."
|
||||||
|
driver.get(albums_overview_url)
|
||||||
|
|
||||||
|
albums_to_visit = []
|
||||||
|
try:
|
||||||
|
album_rows = wait.until(EC.presence_of_all_elements_located((By.XPATH, SELECTORS["album_overview_rows"])))
|
||||||
|
for row in album_rows:
|
||||||
|
try:
|
||||||
|
album_link = row.find_element(By.XPATH, SELECTORS["album_overview_link"])
|
||||||
|
albums_to_visit.append({"name": album_link.text, "url": album_link.get_attribute('href')})
|
||||||
|
except NoSuchElementException:
|
||||||
|
continue
|
||||||
|
except TimeoutException:
|
||||||
|
task_store[task_id] = {"status": "error", "progress": "Konnte die Album-Liste nicht finden."}
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_results = []
|
||||||
|
total_albums = len(albums_to_visit)
|
||||||
|
|
||||||
|
for index, album in enumerate(albums_to_visit):
|
||||||
|
album_name = album['name']
|
||||||
|
task_store[task_id]["progress"] = f"Album {index+1}/{total_albums}: '{album_name}'..."
|
||||||
|
driver.get(album['url'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
total_codes_text = wait.until(EC.visibility_of_element_located((By.XPATH, SELECTORS["access_code_count"]))).text
|
||||||
|
num_pages = math.ceil(int(total_codes_text) / 20)
|
||||||
|
|
||||||
|
for page_num in range(1, num_pages + 1):
|
||||||
|
task_store[task_id]["progress"] = f"Album {index+1}/{total_albums}: '{album_name}' (Seite {page_num}/{num_pages})..."
|
||||||
|
if page_num > 1:
|
||||||
|
driver.get(album['url'] + f"?page_guest_accesses={page_num}")
|
||||||
|
|
||||||
|
person_rows = wait.until(EC.presence_of_all_elements_located((By.XPATH, SELECTORS["person_rows"])))
|
||||||
|
num_persons = len(person_rows)
|
||||||
|
|
||||||
|
for i in range(num_persons):
|
||||||
|
# Re-locate rows to avoid stale element reference
|
||||||
|
person_rows = wait.until(EC.presence_of_all_elements_located((By.XPATH, SELECTORS["person_rows"])))
|
||||||
|
person_row = person_rows[i]
|
||||||
|
|
||||||
|
login_count_text = person_row.find_element(By.XPATH, ".//span[text()='Logins']/following-sibling::strong").text
|
||||||
|
|
||||||
|
# Only interested in people with 0 or 1 logins (potential reminders)
|
||||||
|
# Actually, if they haven't bought yet, they might need a reminder regardless of logins,
|
||||||
|
# but the legacy logic uses login_count <= 1.
|
||||||
|
# Let's stick to the legacy logic for now.
|
||||||
|
if int(login_count_text) <= 1:
|
||||||
|
vorname = person_row.find_element(By.XPATH, ".//span[text()='Vorname']/following-sibling::strong").text
|
||||||
|
|
||||||
|
try:
|
||||||
|
photo_container = person_row.find_element(By.XPATH, "./following-sibling::div[1]")
|
||||||
|
purchase_icons = photo_container.find_elements(By.XPATH, ".//img[@alt='Bestellungen mit diesem Foto']")
|
||||||
|
if len(purchase_icons) > 0:
|
||||||
|
continue
|
||||||
|
except NoSuchElementException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Potential candidate
|
||||||
|
access_code_page_url = person_row.find_element(By.XPATH, ".//a[contains(@data-qa-id, 'guest-access-banner-access-code')]").get_attribute('href')
|
||||||
|
|
||||||
|
# Open in new tab or navigate back and forth?
|
||||||
|
# Scraper.py navigates back and forth.
|
||||||
|
driver.get(access_code_page_url)
|
||||||
|
|
||||||
|
try:
|
||||||
|
wait.until(EC.visibility_of_element_located((By.XPATH, "//a[@id='quick-login-url']")))
|
||||||
|
quick_login_url = driver.find_element(By.XPATH, "//a[@id='quick-login-url']").get_attribute('href')
|
||||||
|
potential_buyer_element = driver.find_element(By.XPATH, "//a[contains(@href, '/config_customers/view_customer')]")
|
||||||
|
buyer_name = potential_buyer_element.text
|
||||||
|
|
||||||
|
potential_buyer_element.click()
|
||||||
|
email = wait.until(EC.visibility_of_element_located((By.XPATH, "//span[contains(., '@')]"))).text
|
||||||
|
|
||||||
|
raw_results.append({
|
||||||
|
"child_name": vorname,
|
||||||
|
"buyer_name": buyer_name,
|
||||||
|
"email": email,
|
||||||
|
"quick_login": quick_login_url
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error getting details for {vorname}: {e}")
|
||||||
|
|
||||||
|
# Go back to the album page
|
||||||
|
driver.get(album['url'] + (f"?page_guest_accesses={page_num}" if page_num > 1 else ""))
|
||||||
|
wait.until(EC.presence_of_element_located((By.XPATH, SELECTORS["person_rows"])))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Fehler bei Album '{album_name}': {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Aggregate Results
|
||||||
|
task_store[task_id]["progress"] = "Aggregiere Ergebnisse..."
|
||||||
|
aggregated_data = {}
|
||||||
|
for res in raw_results:
|
||||||
|
email = res['email']
|
||||||
|
child_name = "Familienbilder" if res['child_name'] == "Familie" else res['child_name']
|
||||||
|
html_link = f'<a href="{res["quick_login"]}">Fotos von {child_name}</a>'
|
||||||
|
|
||||||
|
if email not in aggregated_data:
|
||||||
|
aggregated_data[email] = {
|
||||||
|
'buyer_first_name': res['buyer_name'].split(' ')[0],
|
||||||
|
'email': email,
|
||||||
|
'children': [child_name],
|
||||||
|
'links': [html_link]
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
if child_name not in aggregated_data[email]['children']:
|
||||||
|
aggregated_data[email]['children'].append(child_name)
|
||||||
|
aggregated_data[email]['links'].append(html_link)
|
||||||
|
|
||||||
|
final_list = []
|
||||||
|
for email, data in aggregated_data.items():
|
||||||
|
names = data['children']
|
||||||
|
if len(names) > 2:
|
||||||
|
names_str = ', '.join(names[:-1]) + ' und ' + names[-1]
|
||||||
|
else:
|
||||||
|
names_str = ' und '.join(names)
|
||||||
|
|
||||||
|
final_list.append({
|
||||||
|
'Name Käufer': data['buyer_first_name'],
|
||||||
|
'E-Mail-Adresse Käufer': email,
|
||||||
|
'Kindernamen': names_str,
|
||||||
|
'LinksHTML': '<br><br>'.join(data['links'])
|
||||||
|
})
|
||||||
|
|
||||||
|
task_store[task_id] = {
|
||||||
|
"status": "completed",
|
||||||
|
"progress": "Analyse abgeschlossen!",
|
||||||
|
"result": final_list
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Error in task {task_id}")
|
||||||
|
task_store[task_id] = {"status": "error", "progress": f"Fehler: {str(e)}"}
|
||||||
|
finally:
|
||||||
|
if driver: driver.quit()
|
||||||
|
|
||||||
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
|
||||||
@@ -756,6 +915,31 @@ async def start_statistics(job_id: str, account_type: str, background_tasks: Bac
|
|||||||
background_tasks.add_task(process_statistics, task_id, job_id, account_type)
|
background_tasks.add_task(process_statistics, task_id, job_id, account_type)
|
||||||
return {"task_id": task_id}
|
return {"task_id": task_id}
|
||||||
|
|
||||||
|
@app.post("/api/jobs/{job_id}/reminder-analysis")
|
||||||
|
async def start_reminder_analysis(job_id: str, account_type: str, background_tasks: BackgroundTasks):
|
||||||
|
logger.info(f"API Request: Start reminder analysis for job {job_id} ({account_type})")
|
||||||
|
task_id = str(uuid.uuid4())
|
||||||
|
background_tasks.add_task(process_reminder_analysis, task_id, job_id, account_type)
|
||||||
|
return {"task_id": task_id}
|
||||||
|
|
||||||
|
@app.get("/api/tasks/{task_id}/download-csv")
|
||||||
|
async def download_task_csv(task_id: str):
|
||||||
|
if task_id not in task_store or task_store[task_id]["status"] != "completed":
|
||||||
|
raise HTTPException(status_code=404, detail="Ergebnis nicht gefunden oder Task noch nicht abgeschlossen.")
|
||||||
|
|
||||||
|
result = task_store[task_id]["result"]
|
||||||
|
if not result or not isinstance(result, list):
|
||||||
|
raise HTTPException(status_code=400, detail="Keine Daten zum Exportieren vorhanden.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
df = pd.DataFrame(result)
|
||||||
|
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".csv")
|
||||||
|
df.to_csv(temp_file.name, index=False, encoding='utf-8-sig')
|
||||||
|
return FileResponse(path=temp_file.name, filename=f"Supermailer_Liste_{task_id[:8]}.csv", media_type="text/csv")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Export error: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="CSV Export fehlgeschlagen.")
|
||||||
|
|
||||||
@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):
|
async def generate_pdf(job_id: str, account_type: str):
|
||||||
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})")
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ function App() {
|
|||||||
const [eventTypes, setEventTypes] = useState<any[]>([]);
|
const [eventTypes, setEventTypes] = useState<any[]>([]);
|
||||||
const [selectedEventType, setSelectedEventType] = useState<string>("");
|
const [selectedEventType, setSelectedEventType] = useState<string>("");
|
||||||
const [isListGenerating, setIsListGenerating] = useState(false);
|
const [isListGenerating, setIsListGenerating] = useState(false);
|
||||||
|
const [reminderTaskId, setReminderTaskId] = useState<string | null>(null);
|
||||||
|
const [reminderProgress, setReminderProgress] = useState<string>('');
|
||||||
|
const [isReminderRunning, setIsReminderRunning] = useState(false);
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://192.168.178.6:8002';
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://192.168.178.6:8002';
|
||||||
|
|
||||||
@@ -138,6 +141,32 @@ function App() {
|
|||||||
};
|
};
|
||||||
}, [statsTaskId, isStatsRunning]);
|
}, [statsTaskId, isStatsRunning]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let interval: any;
|
||||||
|
if (reminderTaskId && isReminderRunning) {
|
||||||
|
interval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/tasks/${reminderTaskId}`);
|
||||||
|
if (!res.ok) throw new Error('Task Status Request failed');
|
||||||
|
const data = await res.json();
|
||||||
|
setReminderProgress(data.progress || 'Verarbeite...');
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
setIsReminderRunning(false);
|
||||||
|
// Auto-trigger download or show button? The user wants a CSV.
|
||||||
|
// Let's keep the task ID so we can show a download button.
|
||||||
|
} else if (data.status === 'error') {
|
||||||
|
setError(data.progress || 'Ein Fehler ist aufgetreten.');
|
||||||
|
setIsReminderRunning(false);
|
||||||
|
setReminderTaskId(null);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Polling Error:", err);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
return () => { if (interval) clearInterval(interval); };
|
||||||
|
}, [reminderTaskId, isReminderRunning]);
|
||||||
|
|
||||||
const handleGeneratePdf = async (job: Job) => {
|
const handleGeneratePdf = async (job: Job) => {
|
||||||
setProcessingJobId(job.id);
|
setProcessingJobId(job.id);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -237,6 +266,32 @@ function App() {
|
|||||||
setIsListGenerating(false);
|
setIsListGenerating(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStartReminderAnalysis = async (job: Job) => {
|
||||||
|
setIsReminderRunning(true);
|
||||||
|
setReminderProgress('Starte Analyse...');
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/jobs/${job.id}/reminder-analysis?account_type=${activeTab}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Konnte Analyse nicht starten.');
|
||||||
|
const data = await response.json();
|
||||||
|
setReminderTaskId(data.task_id);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
setIsReminderRunning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadReminderCsv = async (taskId: string) => {
|
||||||
|
try {
|
||||||
|
window.open(`${API_BASE_URL}/api/tasks/${taskId}/download-csv`, '_blank');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError("Download fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
};
|
||||||
const currentJobs = jobsCache[activeTab];
|
const currentJobs = jobsCache[activeTab];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -521,19 +576,42 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tool 3: Follow-up Emails */}
|
{/* Tool 3: Follow-up Emails */}
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-5 hover:border-amber-300 transition-colors shadow-sm">
|
<div className="bg-white border border-gray-200 rounded-xl p-5 hover:border-amber-300 transition-colors shadow-sm">
|
||||||
<div className="flex items-start justify-between mb-2">
|
<div className="flex items-start justify-between mb-2">
|
||||||
<div className="p-2 bg-amber-50 rounded-lg text-amber-600 text-xl">✉️</div>
|
<div className="p-2 bg-amber-50 rounded-lg text-amber-600 text-xl">✉️</div>
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600">Demnächst</span>
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700">Aktiv</span>
|
||||||
</div>
|
</div>
|
||||||
<h5 className="font-bold text-gray-900 mb-1">Nachfass-Mails (Supermailer)</h5>
|
<h5 className="font-bold text-gray-900 mb-1">Nachfass-Mails (Supermailer)</h5>
|
||||||
<p className="text-sm text-gray-500 mb-4 line-clamp-2">Analysiert das Kaufverhalten und generiert eine fertige CSV-Liste für den Supermailer.</p>
|
<p className="text-sm text-gray-500 mb-4 line-clamp-2">Analysiert das Kaufverhalten und generiert eine fertige CSV-Liste für den Supermailer.</p>
|
||||||
<button className="w-full px-4 py-2 bg-gray-100 text-gray-500 text-sm font-medium rounded-lg cursor-not-allowed">
|
|
||||||
|
{isReminderRunning ? (
|
||||||
|
<div className="w-full bg-gray-100 p-3 rounded-lg text-sm text-gray-700 flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="animate-spin h-4 w-4 text-amber-600" 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>
|
||||||
|
<span className="font-medium text-amber-700">Analyse läuft...</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs break-words">{reminderProgress}</p>
|
||||||
|
</div>
|
||||||
|
) : reminderTaskId ? (
|
||||||
|
<button
|
||||||
|
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
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleStartReminderAnalysis(selectedJob)}
|
||||||
|
disabled={processingJobId !== null || isReminderRunning || isStatsRunning}
|
||||||
|
className="w-full px-4 py-2 bg-amber-600 text-white text-sm font-medium rounded-lg hover:bg-amber-700 disabled:opacity-50 transition-colors shadow-sm"
|
||||||
|
>
|
||||||
Analyse starten (Dauert lange)
|
Analyse starten (Dauert lange)
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tool 4: Statistics */}
|
{/* Tool 4: Statistics */}
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-5 hover:border-purple-300 transition-colors shadow-sm">
|
<div className="bg-white border border-gray-200 rounded-xl p-5 hover:border-purple-300 transition-colors shadow-sm">
|
||||||
<div className="flex items-start justify-between mb-2">
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user