[32788f42] Add Termin-Übersicht feature, dynamic Event-Type selection, and refactor QR cards UI into Job Details
This commit is contained in:
@@ -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"}
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
156
fotograf-de-scraper/backend/templates/appointment_list.html
Normal file
156
fotograf-de-scraper/backend/templates/appointment_list.html
Normal file
@@ -0,0 +1,156 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Terminübersicht</title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 20mm;
|
||||
@bottom-right {
|
||||
content: "Seite " counter(page) " von " counter(pages);
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 10pt;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 11pt;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
border-bottom: 2px solid #ddd;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.header-text {
|
||||
flex: 1;
|
||||
}
|
||||
.header-logo {
|
||||
width: 150px;
|
||||
text-align: right;
|
||||
}
|
||||
.header-logo img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
h1 {
|
||||
font-size: 16pt;
|
||||
margin: 0 0 5px 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
h2 {
|
||||
font-size: 14pt;
|
||||
margin: 0 0 10px 0;
|
||||
color: #34495e;
|
||||
}
|
||||
.date-header {
|
||||
background-color: #ecf0f1;
|
||||
padding: 8px 12px;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: bold;
|
||||
font-size: 13pt;
|
||||
border-left: 4px solid #3498db;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 20px;
|
||||
page-break-inside: auto;
|
||||
}
|
||||
tr {
|
||||
page-break-inside: avoid;
|
||||
page-break-after: auto;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #bdc3c7;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.time-col { width: 12%; white-space: nowrap; }
|
||||
.family-col { width: 35%; }
|
||||
.children-col { width: 15%; text-align: center; }
|
||||
.consent-col { width: 20%; text-align: center; }
|
||||
.done-col { width: 18%; text-align: center; }
|
||||
|
||||
.empty-row td {
|
||||
height: 35px; /* ensure enough space for writing */
|
||||
color: transparent; /* visually hide "Empty" text but keep structure if any */
|
||||
}
|
||||
|
||||
/* The checkbox square */
|
||||
.checkbox-square {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1px solid #333;
|
||||
background-color: #fff;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-text">
|
||||
<h1>{{ job_name }}</h1>
|
||||
<h2>Terminübersicht ({{ event_type_name }})</h2>
|
||||
<p>Stand: {{ current_time }}</p>
|
||||
</div>
|
||||
<div class="header-logo">
|
||||
{% if logo_base64 %}
|
||||
<img src="data:image/png;base64,{{ logo_base64 }}" alt="Logo">
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% for date, slots in grouped_slots.items() %}
|
||||
<div class="date-header">{{ date }}</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="time-col">Uhrzeit</th>
|
||||
<th class="family-col">Familie</th>
|
||||
<th class="children-col">Kinder</th>
|
||||
<th class="consent-col">Veröffentlichung</th>
|
||||
<th class="done-col">Erledigt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for slot in slots %}
|
||||
<tr class="{% if not slot.booked %}empty-row{% endif %}">
|
||||
<td class="time-col" style="color: #333;">{{ slot.time_str }}</td>
|
||||
<td class="family-col">{{ slot.name if slot.booked else '' }}</td>
|
||||
<td class="children-col">{{ slot.children if slot.booked else '' }}</td>
|
||||
<td class="consent-col">
|
||||
{% if slot.booked and slot.consent %}
|
||||
<span style="font-size: 16pt;">☑</span>
|
||||
{% elif slot.booked %}
|
||||
<!-- nein -->
|
||||
{% else %}
|
||||
<!-- leer -->
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="done-col">
|
||||
<span class="checkbox-square"></span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endfor %}
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user