[32788f42] Fix font encoding for PDF generation, compress empty slots in appointment list, adjust layout and checkbox positioning
This commit is contained in:
BIN
fotograf-de-scraper/backend/assets/OpenSans-Regular.ttf
Normal file
BIN
fotograf-de-scraper/backend/assets/OpenSans-Regular.ttf
Normal file
Binary file not shown.
@@ -182,6 +182,7 @@ def generate_appointment_overview_pdf(raw_events: list, job_name: str, event_typ
|
||||
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', []):
|
||||
@@ -189,11 +190,12 @@ def generate_appointment_overview_pdf(raw_events: list, job_name: str, event_typ
|
||||
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():
|
||||
elif "veröffentlichen" in q_text or "bilder" in q_text:
|
||||
if "ja" in a_text.lower():
|
||||
has_consent = True
|
||||
|
||||
parsed_events.append({
|
||||
|
||||
"dt": start_dt,
|
||||
"name": event['invitee_name'],
|
||||
"children": num_children,
|
||||
@@ -211,6 +213,7 @@ def generate_appointment_overview_pdf(raw_events: list, job_name: str, event_typ
|
||||
min_dt = events[0]['dt']
|
||||
max_dt = events[-1]['dt']
|
||||
|
||||
|
||||
slots = []
|
||||
curr_dt = min_dt
|
||||
event_idx = 0
|
||||
@@ -230,7 +233,8 @@ def generate_appointment_overview_pdf(raw_events: list, job_name: str, event_typ
|
||||
"name": e['name'],
|
||||
"children": e['children'],
|
||||
"consent": e['consent'],
|
||||
"booked": True
|
||||
"booked": True,
|
||||
"dt": e['dt']
|
||||
})
|
||||
else:
|
||||
if curr_dt <= max_dt:
|
||||
@@ -239,12 +243,54 @@ def generate_appointment_overview_pdf(raw_events: list, job_name: str, event_typ
|
||||
"name": "",
|
||||
"children": "",
|
||||
"consent": False,
|
||||
"booked": False
|
||||
"booked": False,
|
||||
"dt": curr_dt
|
||||
})
|
||||
|
||||
curr_dt = next_dt
|
||||
|
||||
final_grouped[date_str] = slots
|
||||
# Compress empty slots if there are more than 2 in a row
|
||||
compressed_slots = []
|
||||
empty_streak = []
|
||||
|
||||
for slot in slots:
|
||||
if slot["booked"]:
|
||||
if len(empty_streak) > 2:
|
||||
start_time = empty_streak[0]["time_str"]
|
||||
end_dt = empty_streak[-1]["dt"] + datetime.timedelta(minutes=6)
|
||||
end_time = end_dt.strftime("%H:%M")
|
||||
compressed_slots.append({
|
||||
"is_compressed": True,
|
||||
"time_str": f"{start_time} - {end_time}",
|
||||
"name": "--- Freie Zeit / Pause ---",
|
||||
"children": "",
|
||||
"consent": False,
|
||||
"booked": False
|
||||
})
|
||||
else:
|
||||
compressed_slots.extend(empty_streak)
|
||||
empty_streak = []
|
||||
compressed_slots.append(slot)
|
||||
else:
|
||||
empty_streak.append(slot)
|
||||
|
||||
if len(empty_streak) > 2:
|
||||
start_time = empty_streak[0]["time_str"]
|
||||
end_dt = empty_streak[-1]["dt"] + datetime.timedelta(minutes=6)
|
||||
end_time = end_dt.strftime("%H:%M")
|
||||
compressed_slots.append({
|
||||
"is_compressed": True,
|
||||
"time_str": f"{start_time} - {end_time}",
|
||||
"name": "--- Freie Zeit / Pause ---",
|
||||
"children": "",
|
||||
"consent": False,
|
||||
"booked": False
|
||||
})
|
||||
else:
|
||||
compressed_slots.extend(empty_streak)
|
||||
|
||||
final_grouped[date_str] = compressed_slots
|
||||
|
||||
|
||||
template_dir = os.path.join(os.path.dirname(__file__), "templates")
|
||||
env = Environment(loader=FileSystemLoader(template_dir))
|
||||
@@ -582,9 +628,15 @@ async def generate_appointment_list(job_id: str, event_type_name: str, db: Sessi
|
||||
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()
|
||||
|
||||
# 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}"
|
||||
import re
|
||||
job_name_clean = re.sub(r'^JOB\d+\s*', '', job_name)
|
||||
|
||||
|
||||
# 2. Fetch raw Calendly events (no date range needed, defaults to +6 months)
|
||||
try:
|
||||
@@ -603,7 +655,7 @@ async def generate_appointment_list(job_id: str, event_type_name: str, db: Sessi
|
||||
output_path = os.path.join(temp_dir, output_name)
|
||||
|
||||
try:
|
||||
generate_appointment_overview_pdf(raw_events, job_name, event_type_name, output_path)
|
||||
generate_appointment_overview_pdf(raw_events, job_name_clean, 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}")
|
||||
|
||||
@@ -149,6 +149,7 @@ def get_calendly_events(api_token: str, start_time: str = None, end_time: str =
|
||||
has_consent = False
|
||||
questions_and_answers = item.get('questions_and_answers', [])
|
||||
|
||||
|
||||
for q_a in questions_and_answers:
|
||||
q_text = q_a.get('question', '').lower()
|
||||
a_text = q_a.get('answer', '')
|
||||
@@ -157,19 +158,13 @@ def get_calendly_events(api_token: str, start_time: str = None, end_time: str =
|
||||
num_children = a_text
|
||||
elif "nachricht" in q_text or "anmerkung" in q_text:
|
||||
additional_notes = a_text
|
||||
elif "schöne bilder" in q_text and "website veröffentlichen" in q_text:
|
||||
if "ja, gerne" in a_text.lower():
|
||||
elif "veröffentlichen" in q_text or "bilder" in q_text:
|
||||
if "ja" in a_text.lower():
|
||||
has_consent = True
|
||||
|
||||
# Construct the final string: "[☑] Name, X Kinder // HH:MM Uhr (Notes)"
|
||||
# matching: Halime Türe, 1 Kind // 12:00 Uhr
|
||||
final_text = ""
|
||||
if has_consent:
|
||||
# We use a placeholder character or string that we will handle in overlay_text_on_pdf
|
||||
# because standard Helvetica doesn't support Unicode checkbox.
|
||||
final_text += "☑ "
|
||||
|
||||
final_text += f"{name}"
|
||||
|
||||
# Construct the final string: "Name, X Kinder // HH:MM Uhr ☑"
|
||||
final_text = f"{name}"
|
||||
if num_children:
|
||||
final_text += f", {num_children}"
|
||||
|
||||
@@ -177,8 +172,12 @@ def get_calendly_events(api_token: str, start_time: str = None, end_time: str =
|
||||
|
||||
if additional_notes:
|
||||
final_text += f" ({additional_notes})"
|
||||
|
||||
if has_consent:
|
||||
final_text += " ☑"
|
||||
|
||||
formatted_data.append(final_text)
|
||||
|
||||
|
||||
logger.info(f"Processed {len(formatted_data)} invitees.")
|
||||
return formatted_data
|
||||
@@ -219,6 +218,13 @@ def overlay_text_on_pdf(base_pdf_path: str, output_pdf_path: str, texts: list):
|
||||
# We need to process pairs of texts for each page
|
||||
text_pairs = [texts[i:i+2] for i in range(0, len(texts), 2)]
|
||||
|
||||
|
||||
# Load OpenSans font to support UTF-8 extended characters
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
font_path = os.path.join(os.path.dirname(__file__), "assets", "OpenSans-Regular.ttf")
|
||||
pdfmetrics.registerFont(TTFont('OpenSans', font_path))
|
||||
|
||||
for page_idx, pair in enumerate(text_pairs):
|
||||
if page_idx >= total_pages:
|
||||
break # Safety first
|
||||
@@ -228,28 +234,27 @@ def overlay_text_on_pdf(base_pdf_path: str, output_pdf_path: str, texts: list):
|
||||
can = canvas.Canvas(packet, pagesize=A4)
|
||||
|
||||
# Draw the text.
|
||||
# We handle the "☑" character manually since Helvetica might not support it.
|
||||
def draw_text_with_checkbox(can, x, y, text):
|
||||
current_x = x
|
||||
if text.startswith("☑ "):
|
||||
# Draw a checkbox manually
|
||||
can.setFont("OpenSans", 12)
|
||||
if text.endswith(" ☑"):
|
||||
clean_text = text[:-2] # remove the checkmark part
|
||||
can.drawString(x, y, clean_text)
|
||||
|
||||
# Calculate width to place the checkbox right after the text
|
||||
text_width = can.stringWidth(clean_text, "OpenSans", 12)
|
||||
box_x = x + text_width + 8
|
||||
|
||||
size = 10
|
||||
# Draw box (baseline adjustment to align with text)
|
||||
can.rect(x, y - 1, size, size)
|
||||
# Draw checkmark
|
||||
can.rect(box_x, y - 1, size, size)
|
||||
can.setLineWidth(1.5)
|
||||
can.line(x + 2, y + 3, x + 4.5, y + 0.5)
|
||||
can.line(x + 4.5, y + 0.5, x + 8.5, y + 7)
|
||||
can.line(box_x + 2, y + 3, box_x + 4.5, y + 0.5)
|
||||
can.line(box_x + 4.5, y + 0.5, box_x + 8.5, y + 7)
|
||||
can.setLineWidth(1)
|
||||
|
||||
# Move text X position to the right
|
||||
current_x += size + 5
|
||||
text = text[2:] # Remove the "☑ " from string
|
||||
|
||||
can.setFont("Helvetica", 12)
|
||||
can.drawString(current_x, y, text)
|
||||
else:
|
||||
can.drawString(x, y, text)
|
||||
|
||||
if len(pair) > 0:
|
||||
|
||||
draw_text_with_checkbox(can, x_pos, y_pos_1, pair[0])
|
||||
if len(pair) > 1:
|
||||
draw_text_with_checkbox(can, x_pos, y_pos_2, pair[1])
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
margin: 0 0 10px 0;
|
||||
color: #34495e;
|
||||
}
|
||||
|
||||
.date-header {
|
||||
background-color: #ecf0f1;
|
||||
padding: 8px 12px;
|
||||
@@ -57,6 +58,10 @@
|
||||
font-weight: bold;
|
||||
font-size: 13pt;
|
||||
border-left: 4px solid #3498db;
|
||||
page-break-before: always;
|
||||
}
|
||||
.first-date-header {
|
||||
page-break-before: avoid;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
@@ -64,23 +69,25 @@
|
||||
margin-bottom: 20px;
|
||||
page-break-inside: auto;
|
||||
}
|
||||
tr {
|
||||
page-break-inside: avoid;
|
||||
page-break-after: auto;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #bdc3c7;
|
||||
padding: 8px;
|
||||
padding: 6px 8px; /* Narrower rows */
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
.empty-row td {
|
||||
height: 25px; /* Narrower empty rows */
|
||||
color: transparent;
|
||||
}
|
||||
.time-col { width: 12%; white-space: nowrap; }
|
||||
.family-col { width: 35%; }
|
||||
.compressed-row td {
|
||||
background-color: #fcfcfc;
|
||||
color: #7f8c8d !important;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
.time-col { width: 14%; white-space: nowrap; font-weight: bold; }
|
||||
.family-col { width: 33%; }
|
||||
|
||||
.children-col { width: 15%; text-align: center; }
|
||||
.consent-col { width: 20%; text-align: center; }
|
||||
.done-col { width: 18%; text-align: center; }
|
||||
@@ -102,7 +109,14 @@
|
||||
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{% for date, slots in grouped_slots.items() %}
|
||||
|
||||
{% if not loop.first %}
|
||||
<div style="page-break-before: always;"></div>
|
||||
{% endif %}
|
||||
|
||||
<div class="header">
|
||||
<div class="header-text">
|
||||
<h1>{{ job_name }}</h1>
|
||||
@@ -116,8 +130,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% for date, slots in grouped_slots.items() %}
|
||||
<div class="date-header">{{ date }}</div>
|
||||
<div class="date-header first-date-header">{{ date }}</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
@@ -131,26 +144,36 @@
|
||||
</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>
|
||||
{% if slot.is_compressed %}
|
||||
<tr class="compressed-row">
|
||||
<td class="time-col" style="color: #7f8c8d;">{{ slot.time_str }}</td>
|
||||
<td colspan="4">{{ slot.name }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<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: 14pt;">☑</span>
|
||||
{% elif slot.booked %}
|
||||
<!-- nein -->
|
||||
{% else %}
|
||||
<!-- leer -->
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="done-col">
|
||||
{% if slot.booked %}
|
||||
<span class="checkbox-square"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endfor %}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user