[34288f42] Keine Zusammenfassung angegeben.

Keine Zusammenfassung angegeben.
This commit is contained in:
2026-04-14 14:09:58 +00:00
parent 0cca30a956
commit 1a3568f69e
14 changed files with 347 additions and 48 deletions

View File

@@ -1 +1 @@
{"task_id": "34288f42-8544-800e-b866-dfcbc22bd4e5", "token": "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", "readme_path": "readme.md", "session_start_time": "2026-04-14T08:37:49.545740"} {"task_id": "34288f42-8544-800e-b866-dfcbc22bd4e5", "token": "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", "readme_path": "readme.md", "session_start_time": "2026-04-14T14:09:57.182458"}

View File

@@ -53,6 +53,7 @@ Spezielles Tool zur Identifizierung von Geschwistergruppen innerhalb einer Einri
* **Intelligente Erkennung:** Nutzt die "Email der Eltern (1)" aus der Fotograf.de-Anmeldeliste für einen automatischen Abgleich (Zählenwenn > 1). * **Intelligente Erkennung:** Nutzt die "Email der Eltern (1)" aus der Fotograf.de-Anmeldeliste für einen automatischen Abgleich (Zählenwenn > 1).
* **Calendly-Cross-Check:** Gleicht die identifizierten Familien mit allen aktuellen Calendly-Buchungen ab, um Nachmittags-Termine automatisch in der Liste zu vermerken. * **Calendly-Cross-Check:** Gleicht die identifizierten Familien mit allen aktuellen Calendly-Buchungen ab, um Nachmittags-Termine automatisch in der Liste zu vermerken.
* **Optimiertes PDF:** Generiert eine alphabetisch nach Nachnamen sortierte Liste mit Kindern, deren Gruppen, Online-Wunsch-Status und Termin-Uhrzeit (inkl. Datum) sowie einem Erledigt-Feld für die manuelle Kontrolle vor Ort. * **Optimiertes PDF:** Generiert eine alphabetisch nach Nachnamen sortierte Liste mit Kindern, deren Gruppen, Online-Wunsch-Status und Termin-Uhrzeit (inkl. Datum) sowie einem Erledigt-Feld für die manuelle Kontrolle vor Ort.
* **Geschwister-QR-Karten:** Erzeugt automatisch Zugangskarten ("Geschwisterbilder Familie [Nachname]") für alle identifizierten Geschwisterfamilien, die *keinen* Termin am Nachmittag gebucht haben.
--- ---

View File

@@ -1274,7 +1274,10 @@ async def generate_siblings_list(job_id: str, account_type: str, event_type_name
final_storage = os.path.join("/tmp", output_pdf_name) final_storage = os.path.join("/tmp", output_pdf_name)
shutil.copy(output_pdf_path, final_storage) shutil.copy(output_pdf_path, final_storage)
return FileResponse(path=final_storage, filename=output_pdf_name, media_type="application/pdf")
# Since the frontend has trouble triggering a blob download, return a JSON with a download link
download_url = f"/api/jobs/download-qr/{job_id}/{output_pdf_name}"
return JSONResponse(content={"status": "success", "download_url": download_url, "filename": output_pdf_name})
except HTTPException as he: except HTTPException as he:
raise he raise he
@@ -1284,3 +1287,102 @@ async def generate_siblings_list(job_id: str, account_type: str, event_type_name
finally: finally:
if driver: driver.quit() if driver: driver.quit()
@app.post("/api/jobs/{job_id}/siblings-qr-cards")
async def generate_siblings_qr_endpoint(
job_id: str,
account_type: str,
pdf_file: UploadFile = File(...),
db: Session = Depends(get_db)
):
logger.info(f"API Request: Generate siblings QR cards for job {job_id}")
username = os.getenv(f"{account_type.upper()}_USER")
password = os.getenv(f"{account_type.upper()}_PW")
with tempfile.TemporaryDirectory() as temp_dir:
input_pdf_path = os.path.join(temp_dir, "input.pdf")
with open(input_pdf_path, "wb") as buffer:
shutil.copyfileobj(pdf_file.file, buffer)
driver = setup_driver(download_path=temp_dir)
try:
if not login(driver, username, password):
raise HTTPException(status_code=401, detail="Login failed.")
job_url = f"https://app.fotograf.de/config_jobs_settings/index/{job_id}"
driver.get(job_url)
wait = WebDriverWait(driver, 30)
personen_tab = wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, "[data-qa-id='link:photo-jobs-tabs-names_list']")))
driver.execute_script("arguments[0].click();", personen_tab)
export_btn = wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, SELECTORS["export_dropdown"])))
driver.execute_script("arguments[0].scrollIntoView(true);", export_btn)
time.sleep(1)
driver.execute_script("arguments[0].click();", export_btn)
time.sleep(2)
try:
csv_btn = wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, SELECTORS["export_csv_link"])))
driver.execute_script("arguments[0].click();", csv_btn)
except TimeoutException:
raise HTTPException(status_code=500, detail="CSV Export Button nicht gefunden.")
timeout = 45
start_time = time.time()
csv_file = None
while time.time() - start_time < timeout:
files = os.listdir(temp_dir)
csv_files = [f for f in files if f.endswith('.csv')]
if csv_files:
csv_file = os.path.join(temp_dir, csv_files[0])
break
time.sleep(1)
if not csv_file:
raise HTTPException(status_code=500, detail="CSV Download fehlgeschlagen.")
output_pdf_name = f"Geschwister_QR_{job_id}.pdf"
output_pdf_path = os.path.join(temp_dir, output_pdf_name)
from siblings_logic import get_sibling_families_from_csv
# Fetch Calendly events to exclude those who already have a meeting
api_token = os.getenv("CALENDLY_TOKEN")
from qr_generator import get_calendly_events_raw
try:
calendly_events = get_calendly_events_raw(api_token, event_type_name=None)
except:
calendly_events = []
families = get_sibling_families_from_csv(csv_file, calendly_events=calendly_events)
if not families:
raise HTTPException(status_code=404, detail="Keine Geschwisterkinder für QR-Karten gefunden.")
from qr_generator import generate_siblings_qr_overlay
generate_siblings_qr_overlay(input_pdf_path, output_pdf_path, families)
final_storage = os.path.join("/tmp", output_pdf_name)
shutil.copy(output_pdf_path, final_storage)
# Since the frontend has trouble triggering a blob download, return a JSON with a download link
download_url = f"/api/jobs/download-qr/{job_id}/{output_pdf_name}"
return JSONResponse(content={"status": "success", "download_url": download_url, "filename": output_pdf_name})
except HTTPException as he:
raise he
except Exception as e:
logger.exception("Error generating siblings QR cards")
raise HTTPException(status_code=500, detail=str(e))
finally:
if driver: driver.quit()
@app.get("/api/jobs/download-qr/{job_id}/{filename}")
async def download_generated_qr(job_id: str, filename: str):
file_path = os.path.join("/tmp", filename)
if os.path.exists(file_path):
return FileResponse(path=file_path, filename=filename, media_type="application/pdf")
raise HTTPException(status_code=404, detail="File not found")

View File

@@ -292,3 +292,62 @@ def overlay_text_on_pdf(base_pdf_path: str, output_pdf_path: str, texts: list):
writer.write(output_file) writer.write(output_file)
logger.info(f"Successfully generated overlaid PDF at {output_pdf_path}") logger.info(f"Successfully generated overlaid PDF at {output_pdf_path}")
def generate_siblings_qr_overlay(base_pdf_path: str, output_pdf_path: str, families: list):
import io
from PyPDF2 import PdfReader, PdfWriter
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
import os
font_path = os.path.join(os.path.dirname(__file__), "assets", "OpenSans-Regular.ttf")
if os.path.exists(font_path):
pdfmetrics.registerFont(TTFont('OpenSans', font_path))
font_name = 'OpenSans'
else:
font_name = 'Helvetica'
mm_to_pt = 2.83465
page_width, page_height = A4
x_pos = 72 * mm_to_pt
y_pos_1 = page_height - (31 * mm_to_pt)
y_pos_2 = page_height - (180 * mm_to_pt)
reader = PdfReader(base_pdf_path)
writer = PdfWriter()
family_idx = 0
total_families = len(families)
for i in range(len(reader.pages)):
page = reader.pages[i]
if family_idx < total_families:
packet = io.BytesIO()
c = canvas.Canvas(packet, pagesize=A4)
c.setFont(font_name, 11)
# First card on the page
if family_idx < total_families:
text_top = f"Geschwisterbilder Familie {families[family_idx]['nachname']}"
c.drawString(x_pos, y_pos_1, text_top)
family_idx += 1
# Second card on the page
if family_idx < total_families:
text_bottom = f"Geschwisterbilder Familie {families[family_idx]['nachname']}"
c.drawString(x_pos, y_pos_2, text_bottom)
family_idx += 1
c.save()
packet.seek(0)
overlay_pdf = PdfReader(packet)
page.merge_page(overlay_pdf.pages[0])
writer.add_page(page)
with open(output_pdf_path, "wb") as output_file:
writer.write(output_file)

View File

@@ -26,7 +26,7 @@ def generate_siblings_pdf_from_csv(csv_path: str, institution: str, calendly_eve
except: except:
raise Exception("CSV konnte nicht gelesen werden.") raise Exception("CSV konnte nicht gelesen werden.")
df.columns = df.columns.str.strip().str.replace("\"", "") df.columns = df.columns.str.strip().str.replace('"', "")
# Identify Email Column # Identify Email Column
email_col = next((c for c in df.columns if "email" in c.lower()), None) email_col = next((c for c in df.columns if "email" in c.lower()), None)
@@ -56,8 +56,6 @@ def generate_siblings_pdf_from_csv(csv_path: str, institution: str, calendly_eve
try: try:
start_dt = datetime.datetime.fromisoformat(event['start_time'].replace('Z', '+00:00')) start_dt = datetime.datetime.fromisoformat(event['start_time'].replace('Z', '+00:00'))
start_dt = start_dt.astimezone(ZoneInfo("Europe/Berlin")) start_dt = start_dt.astimezone(ZoneInfo("Europe/Berlin"))
# Allow all events for siblings logic, regardless of date, just to be sure we match them
calendly_map[event['invitee_email'].lower().strip()] = start_dt.strftime("%d.%m. %H:%M") calendly_map[event['invitee_email'].lower().strip()] = start_dt.strftime("%d.%m. %H:%M")
except: except:
pass pass
@@ -123,3 +121,63 @@ def generate_siblings_pdf_from_csv(csv_path: str, institution: str, calendly_eve
with open(output_path, "wb") as f: with open(output_path, "wb") as f:
f.write(pdf) f.write(pdf)
logger.info(f"Siblings PDF saved to {output_path}") logger.info(f"Siblings PDF saved to {output_path}")
def get_sibling_families_from_csv(csv_path: str, calendly_events: list = None) -> list:
df = None
for sep in [";", ","]:
try:
test_df = pd.read_csv(csv_path, sep=sep, encoding="utf-8-sig", nrows=5)
if len(test_df.columns) > 1:
df = pd.read_csv(csv_path, sep=sep, encoding="utf-8-sig")
break
except Exception as e:
continue
if df is None:
try:
df = pd.read_csv(csv_path, sep=";", encoding="latin1")
except:
raise Exception("CSV konnte nicht gelesen werden.")
df.columns = df.columns.str.strip().str.replace('"', "")
email_col = next((c for c in df.columns if "email" in c.lower()), None)
if not email_col:
email_col = next((c for c in df.columns if "e-mail" in c.lower()), None)
if not email_col:
return []
lastname_col = next((c for c in df.columns if "nachname" in c.lower()), None)
# Build Calendly Email Set for filtering
booked_emails = set()
if calendly_events:
for event in calendly_events:
email = event.get('invitee_email', '').lower().strip()
if email:
booked_emails.add(email)
families_dict = defaultdict(list)
df = df.fillna("")
for _, row in df.iterrows():
email = str(row[email_col]).strip().lower()
if email and "@" in email:
families_dict[email].append(row)
families = []
for email, rows in families_dict.items():
if len(rows) > 1: # SIBLINGS DETECTED
# FILTER OUT if they already have an appointment
if email in booked_emails:
logger.info(f"Family {email} already has Calendly appointment, skipping QR card.")
continue
family_last_name = str(rows[0][lastname_col]).strip() if lastname_col else "Unbekannt"
families.append({
"nachname": family_last_name
})
families.sort(key=lambda x: x["nachname"])
return families

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 113 B

View File

@@ -4,9 +4,9 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/fotograf-de/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/fotograf-de/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <title>Fotograf.de ERP</title>
<script type="module" crossorigin src="/fotograf-de/assets/index-BPS0v1Hk.js"></script> <script type="module" crossorigin src="/fotograf-de/assets/index-0MFhRTsS.js"></script>
<link rel="stylesheet" crossorigin href="/fotograf-de/assets/index-CMFolvzs.css"> <link rel="stylesheet" crossorigin href="/fotograf-de/assets/index-uaZdwVxZ.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <title>Fotograf.de ERP</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 113 B

View File

@@ -39,6 +39,7 @@ function App() {
const [selectedEventType, setSelectedEventType] = useState<string>(""); const [selectedEventType, setSelectedEventType] = useState<string>("");
const [isListGenerating, setIsListGenerating] = useState(false); const [isListGenerating, setIsListGenerating] = useState(false);
const [isSiblingsGenerating, setIsSiblingsGenerating] = useState(false); const [isSiblingsGenerating, setIsSiblingsGenerating] = useState(false);
const [isSiblingsQrGenerating, setIsSiblingsQrGenerating] = useState(false);
const [reminderTaskId, setReminderTaskId] = useState<string | null>(null); const [reminderTaskId, setReminderTaskId] = useState<string | null>(null);
const [reminderProgress, setReminderProgress] = useState<string>(''); const [reminderProgress, setReminderProgress] = useState<string>('');
const [isReminderRunning, setIsReminderRunning] = useState(false); const [isReminderRunning, setIsReminderRunning] = useState(false);
@@ -152,7 +153,13 @@ function App() {
const data = await res.json(); const data = await res.json();
setEventTypes(data.event_types || []); setEventTypes(data.event_types || []);
if (data.event_types && data.event_types.length > 0) { if (data.event_types && data.event_types.length > 0) {
setSelectedEventType(data.event_types[0].name); const savedType = localStorage.getItem('fotograf_selected_event_type');
const typeExists = data.event_types.some((et: any) => et.name === savedType);
if (savedType && typeExists) {
setSelectedEventType(savedType);
} else {
setSelectedEventType(data.event_types[0].name);
}
} }
} }
} catch (err) { } catch (err) {
@@ -323,6 +330,47 @@ function App() {
}; };
const handleGenerateSiblingsQrCards = async (job: Job, file: File) => {
if (!file) {
setError("Bitte wähle eine PDF-Vorlage aus.");
return;
}
setIsSiblingsQrGenerating(true);
setError(null);
const formData = new FormData();
formData.append('pdf_file', file);
try {
const response = await fetch(`${API_BASE_URL}/api/jobs/${job.id}/siblings-qr-cards?account_type=${activeTab}`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
if (response.status === 404) {
throw new Error("Keine Geschwisterkinder für QR-Karten gefunden.");
}
const errData = await response.json();
throw new Error(errData.detail || 'Generierung fehlgeschlagen');
}
const result = await response.json();
if (result.status === "success" && result.download_url) {
window.open(`${API_BASE_URL}${result.download_url}`, '_blank');
setTimeout(fetchLatestFile, 2000);
} else {
throw new Error("Download URL could not be retrieved from server.");
}
} catch (err: any) {
setError(err.message);
} finally {
setIsSiblingsQrGenerating(false);
}
};
const handleGenerateSiblingsList = async (job: Job) => { const handleGenerateSiblingsList = async (job: Job) => {
setIsSiblingsGenerating(true); setIsSiblingsGenerating(true);
setError(null); setError(null);
@@ -727,7 +775,10 @@ function App() {
<label className="block text-sm font-bold text-gray-700 mb-2">Calendly Event auswählen</label> <label className="block text-sm font-bold text-gray-700 mb-2">Calendly Event auswählen</label>
<select <select
value={selectedEventType} value={selectedEventType}
onChange={(e) => setSelectedEventType(e.target.value)} onChange={(e) => {
setSelectedEventType(e.target.value);
localStorage.setItem('fotograf_selected_event_type', e.target.value);
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 text-sm" className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 text-sm"
> >
{eventTypes.length === 0 && <option value="">Lade Events...</option>} {eventTypes.length === 0 && <option value="">Lade Events...</option>}
@@ -788,13 +839,37 @@ function App() {
<h6 className="font-bold text-sm text-gray-800 mb-1">👨👩👧👦 Geschwisterliste (Einrichtungsintern)</h6> <h6 className="font-bold text-sm text-gray-800 mb-1">👨👩👧👦 Geschwisterliste (Einrichtungsintern)</h6>
<p className="text-xs text-gray-600 mb-3">Sucht nach Geschwisterkindern in der Einrichtung und gleicht diese mit Calendly ab.</p> <p className="text-xs text-gray-600 mb-3">Sucht nach Geschwisterkindern in der Einrichtung und gleicht diese mit Calendly ab.</p>
</div> </div>
<button <div className="grid grid-cols-2 gap-3 mt-auto">
onClick={() => handleGenerateSiblingsList(selectedJob)} <button
disabled={!selectedEventType || isSiblingsGenerating} onClick={() => handleGenerateSiblingsList(selectedJob)}
className="w-full px-3 py-2 bg-teal-600 text-white text-xs font-bold rounded-lg hover:bg-teal-700 disabled:opacity-50 transition-all flex justify-center items-center gap-2 mt-auto" disabled={isSiblingsGenerating}
> className="w-full px-3 py-2 bg-teal-600 text-white text-xs font-bold rounded-lg hover:bg-teal-700 disabled:opacity-50 transition-all flex justify-center items-center gap-2"
{isSiblingsGenerating ? 'Generiere...' : 'Geschwisterliste generieren'} >
</button> {isSiblingsGenerating ? 'Generiere...' : '📄 PDF Liste'}
</button>
<div>
<input
type="file"
accept=".pdf"
id={`siblings-qr-upload-${selectedJob.id}`}
className="hidden"
onChange={(e) => {
if (e.target.files && e.target.files.length > 0) {
handleGenerateSiblingsQrCards(selectedJob, e.target.files[0]);
e.target.value = ''; // reset
}
}}
/>
<button
onClick={() => document.getElementById(`siblings-qr-upload-${selectedJob.id}`)?.click()}
disabled={isSiblingsQrGenerating}
className="w-full px-3 py-2 bg-emerald-600 text-white text-xs font-bold rounded-lg hover:bg-emerald-700 disabled:opacity-50 transition-all flex justify-center items-center gap-2 h-full"
>
{isSiblingsQrGenerating ? 'Generiere...' : '📇 QR-Karten drucken'}
</button>
</div>
</div>
</div> </div>
</div> </div>