Compare commits
2 Commits
2592607b04
...
1a3568f69e
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a3568f69e | |||
| 0cca30a956 |
@@ -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"}
|
||||
@@ -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).
|
||||
* **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.
|
||||
* **Geschwister-QR-Karten:** Erzeugt automatisch Zugangskarten ("Geschwisterbilder Familie [Nachname]") für alle identifizierten Geschwisterfamilien, die *keinen* Termin am Nachmittag gebucht haben.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
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:
|
||||
raise he
|
||||
@@ -1284,3 +1287,102 @@ async def generate_siblings_list(job_id: str, account_type: str, event_type_name
|
||||
finally:
|
||||
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")
|
||||
|
||||
@@ -292,3 +292,62 @@ def overlay_text_on_pdf(base_pdf_path: str, output_pdf_path: str, texts: list):
|
||||
writer.write(output_file)
|
||||
|
||||
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)
|
||||
|
||||
@@ -26,7 +26,7 @@ def generate_siblings_pdf_from_csv(csv_path: str, institution: str, calendly_eve
|
||||
except:
|
||||
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
|
||||
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:
|
||||
start_dt = datetime.datetime.fromisoformat(event['start_time'].replace('Z', '+00:00'))
|
||||
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")
|
||||
except:
|
||||
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:
|
||||
f.write(pdf)
|
||||
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
|
||||
27
fotograf-de-scraper/frontend/dist/assets/index-0MFhRTsS.js
vendored
Normal file
27
fotograf-de-scraper/frontend/dist/assets/index-0MFhRTsS.js
vendored
Normal file
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
1
fotograf-de-scraper/frontend/dist/assets/index-uaZdwVxZ.css
vendored
Normal file
1
fotograf-de-scraper/frontend/dist/assets/index-uaZdwVxZ.css
vendored
Normal file
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 |
6
fotograf-de-scraper/frontend/dist/index.html
vendored
6
fotograf-de-scraper/frontend/dist/index.html
vendored
@@ -4,9 +4,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/fotograf-de/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<script type="module" crossorigin src="/fotograf-de/assets/index-BPS0v1Hk.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/fotograf-de/assets/index-CMFolvzs.css">
|
||||
<title>Fotograf.de ERP</title>
|
||||
<script type="module" crossorigin src="/fotograf-de/assets/index-0MFhRTsS.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/fotograf-de/assets/index-uaZdwVxZ.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<title>Fotograf.de ERP</title>
|
||||
</head>
|
||||
<body>
|
||||
<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 |
@@ -39,6 +39,7 @@ function App() {
|
||||
const [selectedEventType, setSelectedEventType] = useState<string>("");
|
||||
const [isListGenerating, setIsListGenerating] = useState(false);
|
||||
const [isSiblingsGenerating, setIsSiblingsGenerating] = useState(false);
|
||||
const [isSiblingsQrGenerating, setIsSiblingsQrGenerating] = useState(false);
|
||||
const [reminderTaskId, setReminderTaskId] = useState<string | null>(null);
|
||||
const [reminderProgress, setReminderProgress] = useState<string>('');
|
||||
const [isReminderRunning, setIsReminderRunning] = useState(false);
|
||||
@@ -152,7 +153,13 @@ function App() {
|
||||
const data = await res.json();
|
||||
setEventTypes(data.event_types || []);
|
||||
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) {
|
||||
@@ -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) => {
|
||||
setIsSiblingsGenerating(true);
|
||||
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>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
{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>
|
||||
<p className="text-xs text-gray-600 mb-3">Sucht nach Geschwisterkindern in der Einrichtung und gleicht diese mit Calendly ab.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleGenerateSiblingsList(selectedJob)}
|
||||
disabled={!selectedEventType || 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 mt-auto"
|
||||
>
|
||||
{isSiblingsGenerating ? 'Generiere...' : 'Geschwisterliste generieren'}
|
||||
</button>
|
||||
<div className="grid grid-cols-2 gap-3 mt-auto">
|
||||
<button
|
||||
onClick={() => handleGenerateSiblingsList(selectedJob)}
|
||||
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...' : '📄 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>
|
||||
|
||||
@@ -286,3 +286,12 @@ Investierte Zeit in dieser Session: 01:43
|
||||
Arbeitszusammenfassung:
|
||||
Keine Zusammenfassung angegeben.
|
||||
```
|
||||
|
||||
|
||||
## 🤖 Status-Update (2026-04-14 16:09 Berlin Time)
|
||||
```yaml
|
||||
Investierte Zeit in dieser Session: 05:32
|
||||
|
||||
Arbeitszusammenfassung:
|
||||
Keine Zusammenfassung angegeben.
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user