diff --git a/fotograf-de-scraper/backend/main.py b/fotograf-de-scraper/backend/main.py index c68f9750c..a4fa7164d 100644 --- a/fotograf-de-scraper/backend/main.py +++ b/fotograf-de-scraper/backend/main.py @@ -173,6 +173,97 @@ def generate_pdf_from_csv(csv_path: str, institution: str, date_info: str, list_ logger.info(f"Writing PDF to: {output_path}") HTML(string=html_out).write_pdf(output_path) +def generate_appointment_overview_pdf(raw_events: list, job_name: str, event_type_name: str, output_path: str): + from collections import defaultdict + from zoneinfo import ZoneInfo + + parsed_events = [] + for event in raw_events: + start_dt = datetime.datetime.fromisoformat(event['start_time'].replace('Z', '+00:00')) + start_dt = start_dt.astimezone(ZoneInfo("Europe/Berlin")) + + num_children = "" + has_consent = False + for qa in event.get('questions_and_answers', []): + q_text = qa.get('question', '').lower() + a_text = qa.get('answer', '') + if "wie viele kinder" in q_text: + num_children = a_text + elif "schöne bilder" in q_text and "website veröffentlichen" in q_text: + if "ja, gerne" in a_text.lower(): + has_consent = True + + parsed_events.append({ + "dt": start_dt, + "name": event['invitee_name'], + "children": num_children, + "consent": has_consent + }) + + grouped = defaultdict(list) + for e in parsed_events: + date_str = e['dt'].strftime("%d.%m.%Y") + grouped[date_str].append(e) + + final_grouped = {} + for date_str, events in grouped.items(): + events.sort(key=lambda x: x['dt']) + min_dt = events[0]['dt'] + max_dt = events[-1]['dt'] + + slots = [] + curr_dt = min_dt + event_idx = 0 + + while curr_dt <= max_dt or event_idx < len(events): + next_dt = curr_dt + datetime.timedelta(minutes=6) + + events_in_slot = [] + while event_idx < len(events) and events[event_idx]['dt'] < next_dt: + events_in_slot.append(events[event_idx]) + event_idx += 1 + + if events_in_slot: + for e in events_in_slot: + slots.append({ + "time_str": e['dt'].strftime("%H:%M"), + "name": e['name'], + "children": e['children'], + "consent": e['consent'], + "booked": True + }) + else: + if curr_dt <= max_dt: + slots.append({ + "time_str": curr_dt.strftime("%H:%M"), + "name": "", + "children": "", + "consent": False, + "booked": False + }) + + curr_dt = next_dt + + final_grouped[date_str] = slots + + template_dir = os.path.join(os.path.dirname(__file__), "templates") + env = Environment(loader=FileSystemLoader(template_dir)) + template = env.get_template("appointment_list.html") + + current_time = datetime.datetime.now().strftime("%d.%m.%Y %H:%M Uhr") + logo_base64 = get_logo_base64() + + render_context = { + "job_name": job_name, + "event_type_name": event_type_name or "Alle Events", + "current_time": current_time, + "logo_base64": logo_base64, + "grouped_slots": final_grouped + } + + html_out = template.render(render_context) + HTML(string=html_out).write_pdf(output_path) + # --- Selenium Scraper Functions --- def take_error_screenshot(driver, error_name): @@ -410,10 +501,22 @@ from sqlalchemy.orm import Session from database import get_db, Job as DBJob, engine, Base import math import uuid -from qr_generator import get_calendly_events, overlay_text_on_pdf +from qr_generator import get_calendly_events, overlay_text_on_pdf, get_calendly_event_types # --- API Endpoints --- +@app.get("/api/calendly/event-types") +async def fetch_calendly_event_types(): + api_token = os.getenv("CALENDLY_TOKEN") + if not api_token: + raise HTTPException(status_code=400, detail="Calendly API token missing.") + try: + types = get_calendly_event_types(api_token) + return {"event_types": types} + except Exception as e: + logger.error(f"Error fetching Calendly event types: {e}") + raise HTTPException(status_code=500, detail=str(e)) + @app.get("/api/calendly/events") async def fetch_calendly_events(start_time: str, end_time: str, event_type_name: Optional[str] = None): """ @@ -434,8 +537,8 @@ async def fetch_calendly_events(start_time: str, end_time: str, event_type_name: @app.post("/api/qr-cards/generate") async def generate_qr_cards( - start_time: str = Form(...), - end_time: str = Form(...), + start_time: str = Form(None), + end_time: str = Form(None), event_type_name: str = Form(None), pdf_file: UploadFile = File(...) ): @@ -472,6 +575,40 @@ async def generate_qr_cards( logger.error(f"Error generating QR cards: {e}") raise HTTPException(status_code=500, detail=str(e)) +@app.get("/api/jobs/{job_id}/appointment-list") +async def generate_appointment_list(job_id: str, event_type_name: str, db: Session = Depends(get_db)): + logger.info(f"API Request: Generate appointment list for job {job_id}, event_type '{event_type_name}'") + api_token = os.getenv("CALENDLY_TOKEN") + if not api_token: + raise HTTPException(status_code=400, detail="Calendly API token missing.") + + # 1. Fetch job name from DB + job = db.query(DBJob).filter(DBJob.id == job_id).first() + job_name = job.name if job else f"Auftrag {job_id}" + + # 2. Fetch raw Calendly events (no date range needed, defaults to +6 months) + try: + from qr_generator import get_calendly_events_raw + raw_events = get_calendly_events_raw(api_token, event_type_name=event_type_name) + except Exception as e: + logger.error(f"Error fetching raw Calendly events: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + if not raw_events: + return JSONResponse(status_code=404, content={"message": "Keine passenden Termine für diesen Event-Typ gefunden."}) + + # 3. Generate PDF + temp_dir = tempfile.gettempdir() + output_name = f"Terminuebersicht_{job_id}_{datetime.datetime.now().strftime('%Y%m%d')}.pdf" + output_path = os.path.join(temp_dir, output_name) + + try: + generate_appointment_overview_pdf(raw_events, job_name, event_type_name, output_path) + return FileResponse(path=output_path, filename=output_name, media_type="application/pdf") + except Exception as e: + logger.error(f"Error generating appointment overview pdf: {e}") + raise HTTPException(status_code=500, detail=str(e)) + @app.get("/health") async def health_check(): return {"status": "ok"} diff --git a/fotograf-de-scraper/backend/qr_generator.py b/fotograf-de-scraper/backend/qr_generator.py index 52a127613..49c459ebe 100644 --- a/fotograf-de-scraper/backend/qr_generator.py +++ b/fotograf-de-scraper/backend/qr_generator.py @@ -11,7 +11,38 @@ import logging logger = logging.getLogger("qr-card-generator") -def get_calendly_events_raw(api_token: str, start_time: str, end_time: str, event_type_name: str = None): +def get_calendly_event_types(api_token: str): + """ + Fetches available event types for the current user. + """ + headers = { + 'Authorization': f'Bearer {api_token}', + 'Content-Type': 'application/json' + } + + # 1. Get current user info + user_url = "https://api.calendly.com/users/me" + user_response = requests.get(user_url, headers=headers) + if not user_response.ok: + raise Exception(f"Calendly API Error: {user_response.status_code}") + + user_data = user_response.json() + user_uri = user_data['resource']['uri'] + + # 2. Get event types + event_types_url = "https://api.calendly.com/event_types" + params = { + 'user': user_uri + } + + types_response = requests.get(event_types_url, headers=headers, params=params) + if not types_response.ok: + raise Exception(f"Calendly API Error: {types_response.status_code}") + + types_data = types_response.json() + return types_data['collection'] + +def get_calendly_events_raw(api_token: str, start_time: str = None, end_time: str = None, event_type_name: str = None): """ Debug function to fetch raw Calendly data without formatting. """ @@ -20,6 +51,12 @@ def get_calendly_events_raw(api_token: str, start_time: str, end_time: str, even 'Content-Type': 'application/json' } + # Defaults: now to +180 days + if not start_time: + start_time = datetime.datetime.utcnow().isoformat() + "Z" + if not end_time: + end_time = (datetime.datetime.utcnow() + datetime.timedelta(days=180)).isoformat() + "Z" + # 1. Get current user info to get the user URI user_url = "https://api.calendly.com/users/me" user_response = requests.get(user_url, headers=headers) @@ -75,7 +112,7 @@ def get_calendly_events_raw(api_token: str, start_time: str, end_time: str, even return raw_results -def get_calendly_events(api_token: str, start_time: str, end_time: str, event_type_name: str = None): +def get_calendly_events(api_token: str, start_time: str = None, end_time: str = None, event_type_name: str = None): """ Fetches events from Calendly API for the current user within a time range. """ diff --git a/fotograf-de-scraper/backend/templates/appointment_list.html b/fotograf-de-scraper/backend/templates/appointment_list.html new file mode 100644 index 000000000..32aeafca5 --- /dev/null +++ b/fotograf-de-scraper/backend/templates/appointment_list.html @@ -0,0 +1,156 @@ + + + + + Terminübersicht + + + +
+
+

{{ job_name }}

+

Terminübersicht ({{ event_type_name }})

+

Stand: {{ current_time }}

+
+ +
+ + {% for date, slots in grouped_slots.items() %} +
{{ date }}
+ + + + + + + + + + + + + {% for slot in slots %} + + + + + + + + {% endfor %} + +
UhrzeitFamilieKinderVeröffentlichungErledigt
{{ slot.time_str }}{{ slot.name if slot.booked else '' }}{{ slot.children if slot.booked else '' }} + +
+ {% endfor %} + + \ No newline at end of file diff --git a/fotograf-de-scraper/frontend/src/App.tsx b/fotograf-de-scraper/frontend/src/App.tsx index 80c89bdec..579365ce7 100644 --- a/fotograf-de-scraper/frontend/src/App.tsx +++ b/fotograf-de-scraper/frontend/src/App.tsx @@ -33,13 +33,12 @@ function App() { const [isStatsRunning, setIsStatsRunning] = useState(false); // States for QR Generator - const [isQrModalOpen, setIsQrModalOpen] = useState(false); - const [qrStartTime, setQrStartTime] = useState(new Date().toISOString().split('T')[0]); - const [qrEndTime, setQrEndTime] = useState(new Date().toISOString().split('T')[0]); - const [qrEventType, setQrEventType] = useState('Familie'); - const [qrPdfFile, setQrPdfFile] = useState(null); const [isQrGenerating, setIsQrGenerating] = useState(false); + const [eventTypes, setEventTypes] = useState([]); + const [selectedEventType, setSelectedEventType] = useState(""); + const [isListGenerating, setIsListGenerating] = useState(false); + const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://192.168.178.6:8002'; const fetchJobs = async (account: AccountType, forceRefresh = false) => { @@ -68,6 +67,24 @@ function App() { }, [activeTab]); const handleRefresh = () => fetchJobs(activeTab, true); + useEffect(() => { + const fetchEventTypes = async () => { + try { + const res = await fetch(`${API_BASE_URL}/api/calendly/event-types`); + if (res.ok) { + const data = await res.json(); + setEventTypes(data.event_types || []); + if (data.event_types && data.event_types.length > 0) { + setSelectedEventType(data.event_types[0].name); + } + } + } catch (err) { + console.error("Failed to fetch event types:", err); + } + }; + fetchEventTypes(); + }, []); + // Statistics Task Functions const handleStartStatistics = async (job: Job) => { @@ -147,8 +164,9 @@ function App() { } }; - const handleGenerateQrCards = async () => { - if (!qrPdfFile) { + + const handleGenerateQrCards = async (job: Job, file: File) => { + if (!file) { setError("Bitte wähle eine PDF-Vorlage aus."); return; } @@ -157,10 +175,8 @@ function App() { setError(null); const formData = new FormData(); - formData.append('pdf_file', qrPdfFile); - formData.append('start_time', `${qrStartTime}T00:00:00Z`); - formData.append('end_time', `${qrEndTime}T23:59:59Z`); - if (qrEventType) formData.append('event_type_name', qrEventType); + formData.append('pdf_file', file); + if (selectedEventType) formData.append('event_type_name', selectedEventType); try { const response = await fetch(`${API_BASE_URL}/api/qr-cards/generate`, { @@ -170,7 +186,7 @@ function App() { if (!response.ok) { if (response.status === 404) { - throw new Error("Keine passenden Calendly-Termine in diesem Zeitraum gefunden."); + throw new Error("Keine passenden Calendly-Termine gefunden."); } const errData = await response.json(); throw new Error(errData.detail || 'Generierung fehlgeschlagen'); @@ -180,13 +196,11 @@ function App() { const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; - a.download = `QR_Karten_Andruck_${qrStartTime}.pdf`; + a.download = `QR_Karten_Andruck_${job.id}.pdf`; document.body.appendChild(a); a.click(); a.remove(); window.URL.revokeObjectURL(url); - - setIsQrModalOpen(false); } catch (err: any) { setError(err.message); } finally { @@ -194,6 +208,35 @@ function App() { } }; + const handleGenerateAppointmentList = async (job: Job) => { + setIsListGenerating(true); + setError(null); + try { + const response = await fetch(`${API_BASE_URL}/api/jobs/${job.id}/appointment-list?event_type_name=${encodeURIComponent(selectedEventType)}`); + + if (!response.ok) { + if (response.status === 404) { + throw new Error("Keine passenden Calendly-Termine gefunden."); + } + const errData = await response.json(); + throw new Error(errData.detail || 'PDF Generierung fehlgeschlagen'); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `Terminuebersicht_${job.name.replace(/\s+/g, "_")}.pdf`; + document.body.appendChild(a); + a.click(); + a.remove(); + window.URL.revokeObjectURL(url); + } catch (err: any) { + setError(`Listen-Fehler (${job.name}): ${err.message}`); + } finally { + setIsListGenerating(false); + } + }; const currentJobs = jobsCache[activeTab]; return ( @@ -227,14 +270,7 @@ function App() { -
- -
+ @@ -411,20 +447,81 @@ function App() { - {/* Tool 2: QR Cards */} -
-
-
📇
- Demnächst + + {/* Tool 2: Shooting-Planung (QR & List) */} +
+
+
+
📆
+
Shooting-Planung
+
+ Neu
-
QR-Zugangskarten
-

Druckt Namen und Buchungszeiten auf vorbereitete PDF-Bögen für Familienfotos.

- -
+ +
+ {/* Event Selection */} +
+ + +

Die Termine für diesen Event-Typ werden aus Calendly importiert.

+
- {/* Tool 3: Follow-up Emails */} + {/* Actions */} +
+ {/* Action 1: QR Cards */} +
+
+
📇 QR-Zugangskarten
+

Druckt Namen und Uhrzeit auf vorbereitete PDF-Bögen.

+ { + if (e.target.files && e.target.files.length > 0) { + handleGenerateQrCards(selectedJob, e.target.files[0]); + e.target.value = ''; // reset + } + }} + /> +
+ +
+ + {/* Action 2: Appointment List */} +
+
+
📄 Termin-Übersicht
+

PDF mit 6-Minuten Taktung und Lücken für den Shooting-Tag.

+
+ +
+
+
+
+{/* Tool 3: Follow-up Emails */}
✉️
@@ -535,104 +632,7 @@ function App() {
)} - {/* --- QR Generator Modal --- */} - {isQrModalOpen && ( -
-
-
setIsQrModalOpen(false)}>
- - -
-
-

QR-Karten Generator

- -
- -
- {error && ( -
- {error} -
- )} - -
-
-
- - setQrStartTime(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" - /> -
-
- - setQrEndTime(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" - /> -
-
- -
- - setQrEventType(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" - /> -
- -
- - setQrPdfFile(e.target.files?.[0] || null)} - className="w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-emerald-50 file:text-emerald-700 hover:file:bg-emerald-100 cursor-pointer" - /> -
-
- -
-

Info

-

- Das Tool lädt alle passenden Termine aus deinem Calendly-Account und druckt Name, Personenanzahl und Uhrzeit exakt auf die hochgeladene Vorlage (2 Karten pro Seite). -

-
-
- -
- - -
-
-
-
- )} +
); }