Compare commits

...

10 Commits

5 changed files with 388 additions and 101 deletions
+149 -72
View File
@@ -18,9 +18,11 @@ Collection Structure:
- Status fields: slots_used_by - Status fields: slots_used_by
""" """
import Web.modules.database.settings as cfg import Web.modules.database.settings as cfg
import Web.modules.inventarsystem.data_protection as dp
from Web.modules.database.settings import MongoClient from Web.modules.database.settings import MongoClient
from bson.objectid import ObjectId from bson.objectid import ObjectId
import datetime import datetime
import ast
def _get_tenant_db(client): def _get_tenant_db(client):
@@ -37,8 +39,47 @@ def _active_record_query(extra_query=None):
base_query.update(extra_query) base_query.update(extra_query)
return base_query return base_query
def _decrypt_appointment(item):
"""Helper function to safely decrypt appointment fields back to their original types."""
if not item:
return item
try:
if 'user' in item and item['user']:
item['user'] = dp.decrypt_text(item['user'])
if 'note' in item and item['note']:
item['note'] = dp.decrypt_text(item['note'])
if 'title' in item and item['title']:
item['title'] = dp.decrypt_text(item['title'])
if 'mail' in item and item['mail']:
decrypted_mail = dp.decrypt_text(item['mail'])
try:
item['mail'] = ast.literal_eval(decrypted_mail)
except Exception:
item['mail'] = decrypted_mail
if 'custom_fields' in item and item['custom_fields']:
item['custom_fields'] = [dp.decrypt_text(field) for field in item['custom_fields']]
if 'slots_booked' in item and item['slots_booked']:
# If it's a string, it was encrypted during an update execution
if isinstance(item['slots_booked'], str):
decrypted_slots = dp.decrypt_text(item['slots_booked'])
try:
item['slots_booked'] = ast.literal_eval(decrypted_slots)
except Exception:
item['slots_booked'] = decrypted_slots
except Exception as e:
print(f"Error during decryption: {e}")
return item
def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False, title: str="", custom_fields: list = (), clients_p_slot: int=1): def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False, title: str="", custom_fields: list = (), clients_p_slot: int=1):
client = None
try: try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client) db = _get_tenant_db(client)
@@ -50,11 +91,11 @@ def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
'time_span': time_span, 'time_span': time_span,
'slots': slots, 'slots': slots,
'slot_lenght': slot_lenght, 'slot_lenght': slot_lenght,
'user': user, 'user': dp.encrypt_text(user.strip()),
'mail': mail, 'mail': dp.encrypt_text(str(mail)),
'note': note, 'note': dp.encrypt_text(note),
'title': title, 'title': dp.encrypt_text(title),
'custom_fields': custom_fields, 'custom_fields': [dp.encrypt_text(str(field)) for field in custom_fields],
'calendar_enabled': bool(calendar_enabled), 'calendar_enabled': bool(calendar_enabled),
'clients_per_slot': clients_p_slot, 'clients_per_slot': clients_p_slot,
'slots_booked': [], # -> [(start_time, (names),(custom1, custom2,...)), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable 'slots_booked': [], # -> [(start_time, (names),(custom1, custom2,...)), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable
@@ -64,48 +105,39 @@ def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
result = items.insert_one(item) result = items.insert_one(item)
return result.inserted_id return result.inserted_id
except Exception as e: except Exception as e:
print(f"Exception accured: {e}") print(f"Exception occurred in add: {e}")
return None
finally:
if client:
client.close()
def get_item(id): def get_item(id):
""" """Retrieve a specific appointment by its ID and decrypt it."""
Retrieve a specific appointment by its ID. client = None
Args:
id (str): ID of the appointsment to retrieve
Returns:
dict: The appointment document or None if not found
"""
try: try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client) db = _get_tenant_db(client)
items = db['appointments'] items = db['appointments']
item = items.find_one(_active_record_query({'_id': ObjectId(id)})) item = items.find_one(_active_record_query({'_id': ObjectId(id)}))
client.close()
return item return _decrypt_appointment(item)
except Exception as e: except Exception as e:
print(f"Error retrieving item: {e}") print(f"Error retrieving item: {e}")
return None return None
finally:
if client:
client.close()
def update(id, slots_used: list): def update(id, slots_used: list):
""" """Update an existing appointment's booked slots securely."""
Update an existing appointment. client = None
Args:
id (str): ID of the item to update
Returns:
bool: True if successful, False otherwise
"""
try: try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client) db = _get_tenant_db(client)
items = db['appointments'] items = db['appointments']
update_data = { update_data = {
'slots_booked': slots_used, 'slots_booked': dp.encrypt_text(str(slots_used)),
'LastUpdated': datetime.datetime.now() 'LastUpdated': datetime.datetime.now()
} }
@@ -114,53 +146,81 @@ def update(id,slots_used: list):
{'$set': update_data} {'$set': update_data}
) )
client.close()
return result.modified_count > 0 return result.modified_count > 0
except Exception as e: except Exception as e:
print(f"Error updating item: {e}") print(f"Error updating item: {e}")
return False return False
finally:
if client:
client.close()
def remove_slot(id, date_start_time, name): def remove_slot(id, date_start_time, name):
""" """
Remove a booked slot from an appointment's `slots_booked`. Remove a booked slot from an appointment's encrypted `slots_booked` list.
Args: Because the array is stored as an encrypted string blob, we must decrypt,
id (str): Appointment ID modify it in Python, and re-encrypt it.
date_start_time: The start time value used when booking
name (str): Name associated with the booking
Returns:
bool: True if a slot was removed, False otherwise
""" """
client = None
try: try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client) db = _get_tenant_db(client)
items = db['appointments'] items = db['appointments']
# Attempt to pull the exact element (stored as an array/tuple) item = items.find_one({'_id': ObjectId(id)})
if not item or 'slots_booked' not in item or not item['slots_booked']:
return False
try:
decrypted_slots = dp.decrypt_text(item['slots_booked'])
slots_list = ast.literal_eval(decrypted_slots)
except Exception as e:
print(f"Failed to decrypt or parse slots: {e}")
return False
# Structure format note: [(start_time, (names), (custom1, custom2...)), ...]
updated_slots = []
removed_any = False
for slot in slots_list:
slot_start = slot[0]
slot_names = slot[1]
if slot_start == date_start_time and (slot_names == name or name in slot_names):
removed_any = True
continue
updated_slots.append(slot)
if not removed_any:
return False
result = items.update_one( result = items.update_one(
{'_id': ObjectId(id)}, {'_id': ObjectId(id)},
{'$pull': {'slots_booked': [date_start_time, name]}} {
'$set': {
'slots_booked': dp.encrypt_text(str(updated_slots)),
'LastUpdated': datetime.datetime.now()
}
}
) )
client.close()
return result.modified_count > 0 return result.modified_count > 0
except Exception as e: except Exception as e:
print(f"Error removing slot: {e}") print(f"Error removing slot: {e}")
return False return False
finally:
if client:
client.close()
def remove(id): def remove(id):
""" """
Soft-delete an appointment by setting its `Deleted` flag. Hard-delete an appointment plan by its ID.
(Note: If your docstring mentions a soft-delete 'Deleted' flag,
Args: change items.delete_one to items.update_one with {'$set': {'Deleted': True}})
id (str): Appointment ID
Returns:
bool: True if the appointment was marked deleted, False otherwise
""" """
client = None
try: try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client) db = _get_tenant_db(client)
@@ -168,70 +228,87 @@ def remove(id):
result = items.delete_one({'_id': ObjectId(id)}) result = items.delete_one({'_id': ObjectId(id)})
client.close()
return result.deleted_count > 0 return result.deleted_count > 0
except Exception as e: except Exception as e:
print(f"Error removing appointment: {e}") print(f"Error removing appointment: {e}")
return False return False
finally:
if client:
client.close()
def remove_done(): def remove_done():
"""removose already finisched appointments""" """Remove all expired appointments whose end date is prior to today in a single call."""
client = None
try: try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client) db = _get_tenant_db(client)
items = db['appointments'] items = db['appointments']
today = datetime.date.today().strftime('%Y-%m-%d') today = datetime.date.today().strftime('%Y-%m-%d')
removed_count = 0
cursor = items.find( result = items.delete_many(
_active_record_query( _active_record_query(
{ {
'date_end': {'$lt': today}, 'date_end': {'$lt': today},
} }
) )
).sort('date_start', 1) )
for item in cursor: return result.deleted_count > 0
item['_id'] = str(item.get('_id'))
result = items.delete_one({'_id': ObjectId(item['_id'])})
removed_count += result.deleted_count
client.close()
return removed_count > 0
except Exception as e: except Exception as e:
print(f"Error removing appointment: {e}") print(f"Error cleaning up finished appointments: {e}")
return False return False
finally:
if client:
client.close()
def get_upcoming_for_user(user: str, limit: int = 25): def get_upcoming_for_user(user: str, limit: int = 25):
"""Return upcoming appointment plans for a user ordered by start date.""" """
Return upcoming appointment plans for a user, handling encrypted database records.
"""
try:
if hasattr(globals(), 'remove_done'):
remove_done() remove_done()
except Exception:
pass
client = None
try: try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client) db = _get_tenant_db(client)
items = db['appointments'] items = db['appointments']
today = datetime.date.today().strftime('%Y-%m-%d') today = datetime.date.today().strftime('%Y-%m-%d')
target_user = str(user or '').strip()
cursor = items.find( cursor = items.find(
_active_record_query( _active_record_query({
{
'user': str(user or '').strip(),
'date_end': {'$gte': today}, 'date_end': {'$gte': today},
} })
)
).sort('date_start', 1) ).sort('date_start', 1)
results = [] results = []
for item in cursor: for item in cursor:
item['_id'] = str(item.get('_id')) decrypted_item = _decrypt_appointment(item)
results.append(item) if not decrypted_item:
continue
if decrypted_item.get('user', '').strip() != target_user:
continue
decrypted_item['_id'] = str(decrypted_item.get('_id'))
results.append(decrypted_item)
if len(results) >= max(1, int(limit)): if len(results) >= max(1, int(limit)):
break break
client.close()
return results return results
except Exception as e: except Exception as e:
print(f"Error retrieving upcoming appointments: {e}") print(f"Error retrieving upcoming appointments: {e}")
return [] return []
finally:
if client:
client.close()
+9 -7
View File
@@ -8,6 +8,7 @@ import Web.modules.emailservice.email as mail_service
import Web.modules.database.termine as termin import Web.modules.database.termine as termin
import Web.modules.database.settings as cfg import Web.modules.database.settings as cfg
from Web.tenant import get_tenant_context from Web.tenant import get_tenant_context
import Web.modules.inventarsystem.data_protection as dp
def _resolve_public_base_url() -> str: def _resolve_public_base_url() -> str:
@@ -99,8 +100,11 @@ def build_calendar_ics(appointment_id: str) -> str | None:
return None return None
uid = f"terminplaner-{appointment_id}@invario.eu" uid = f"terminplaner-{appointment_id}@invario.eu"
created_at = datetime.datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')
summary = f"Terminplan für {creator}" created_at = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ')
summary = titel if titel else f"Terminplan für {creator}"
description_lines = [ description_lines = [
f"Buchungslink: {link}", f"Buchungslink: {link}",
f"Zeitraum: {date_start} bis {date_end}", f"Zeitraum: {date_start} bis {date_end}",
@@ -109,7 +113,7 @@ def build_calendar_ics(appointment_id: str) -> str | None:
description_lines.append('Zeitfenster: ' + '; '.join(str(entry) for entry in time_span)) description_lines.append('Zeitfenster: ' + '; '.join(str(entry) for entry in time_span))
if note: if note:
description_lines.append('Notiz: ' + str(note)) description_lines.append('Notiz: ' + str(note))
if titel: if titel and not summary == titel:
description_lines.append('Titel: ' + str(titel)) description_lines.append('Titel: ' + str(titel))
ics_lines = [ ics_lines = [
@@ -125,8 +129,7 @@ def build_calendar_ics(appointment_id: str) -> str | None:
f'DESCRIPTION:{_escape_ics_text(chr(10).join(description_lines))}', f'DESCRIPTION:{_escape_ics_text(chr(10).join(description_lines))}',
f'URL:{_escape_ics_text(link)}', f'URL:{_escape_ics_text(link)}',
f'DTSTART;VALUE=DATE:{_format_ics_date(start_date)}', f'DTSTART;VALUE=DATE:{_format_ics_date(start_date)}',
f'DTEND;VALUE=DATE:{_format_ics_date(end_date + timedelta(days=1))}', f'DTEND;VALUE=DATE:{_format_ics_date(end_date + datetime.timedelta(days=1))}',
f'Titel:{_escape_ics_text(titel)}',
'END:VEVENT', 'END:VEVENT',
'END:VCALENDAR', 'END:VCALENDAR',
'', '',
@@ -172,7 +175,7 @@ def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str
] ]
uid = f"terminplaner-slot-{appointment_id}-{start_dt.strftime('%Y%m%d%H%M')}@invario.eu" uid = f"terminplaner-slot-{appointment_id}-{start_dt.strftime('%Y%m%d%H%M')}@invario.eu"
created_at = datetime.datetime.utcnow().strftime('%Y%m%dT%H%M%SZ') created_at = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ')
dt_start = start_dt.strftime('%Y%m%dT%H%M%S') dt_start = start_dt.strftime('%Y%m%dT%H%M%S')
dt_end = end_dt.strftime('%Y%m%dT%H%M%S') dt_end = end_dt.strftime('%Y%m%dT%H%M%S')
@@ -405,7 +408,6 @@ def get_available(id):
def get_available_user(id): def get_available_user(id):
return get_available(id) return get_available(id)
def get_user_upcoming_events(user: str, limit: int = 25) -> list[dict]: def get_user_upcoming_events(user: str, limit: int = 25) -> list[dict]:
user_name = str(user or '').strip() user_name = str(user or '').strip()
if not user_name: if not user_name:
+164 -13
View File
@@ -1,17 +1,56 @@
from flask import Blueprint, render_template, request, session, url_for, redirect, flash from flask import Blueprint, render_template, request, session, url_for, redirect, flash, make_response, Response, send_file
from flask import Response
import Web.modules.terminplaner.backend_server as appointment_service import Web.modules.terminplaner.backend_server as appointment_service
import Web.modules.database.settings as cfg import Web.modules.database.settings as cfg
import Web.modules.database.termine as termin import Web.modules.database.termine as termin
import Web.modules.database.user as us import Web.modules.database.user as us
import csv import csv
import io import io
from flask import make_response, flash, redirect, url_for, session import qrcode
import os
import tempfile
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import cm
from reportlab.lib.colors import grey, HexColor
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image
# Create a blueprint instance # Create a blueprint instance
appoint_bp = Blueprint('terminplaner', __name__) appoint_bp = Blueprint('terminplaner', __name__)
def _get_school_info_for_export():
"""
Get school information for PDF exports from configuration or database.
Returns default info if not configured.
"""
try:
if hasattr(cfg, 'get_school_info'):
return cfg.get_school_info()
school_info = {
'name': 'Schulname',
'address': 'Schuladresse',
'postal_code': 'PLZ',
'city': 'Stadt',
'school_number': '000000',
'it_admin': 'IT-Beauftragter/in',
'logo_path': '',
}
return school_info
except Exception:
# Return defaults if anything fails
return {
'name': 'Schulname',
'address': 'Schuladresse',
'postal_code': 'PLZ',
'city': 'Stadt',
'school_number': '000000',
'it_admin': 'IT-Beauftragter/in',
'logo_path': '',
}
def _require_module_enabled(): def _require_module_enabled():
if not cfg.MODULES.is_enabled('terminplan'): if not cfg.MODULES.is_enabled('terminplan'):
flash('Der Terminplaner ist deaktiviert.', 'info') flash('Der Terminplaner ist deaktiviert.', 'info')
@@ -307,20 +346,12 @@ def configure():
flash('Fehler beim Erstellen des Terminplans.', 'error') flash('Fehler beim Erstellen des Terminplans.', 'error')
return redirect(url_for('terminplaner.configure')) return redirect(url_for('terminplaner.configure'))
# Resolve the URL string here using Flask's native url_for instead of relying on the database layer
generated_link = url_for(
'terminplaner.client',
appointment_id=str(inserted_id),
tenant=_current_tenant_id() or None,
_external=True
)
flash('Der Terminplan wurde angelegt.', 'success') flash('Der Terminplan wurde angelegt.', 'success')
return render_template( return render_template(
'termin_configure.html', 'termin_configure.html',
school_periods=cfg.SCHOOL_PERIODS, school_periods=cfg.SCHOOL_PERIODS,
generated_link=generated_link, generated_link=inserted_id['link'],
calendar_link=None, # Update with calendar service link generation if needed calendar_link=None,
add_to_calendar=add_to_calendar, add_to_calendar=add_to_calendar,
email_service_enabled=cfg.EMAIL_ENABLED, email_service_enabled=cfg.EMAIL_ENABLED,
title=title, title=title,
@@ -372,6 +403,125 @@ def client_slot_calendar_export(appointment_id):
response.headers['Content-Disposition'] = f'attachment; filename=termin-{title}-{appointment_id}-{slot_start.replace(" ", "_").replace(":", "")}.ics' response.headers['Content-Disposition'] = f'attachment; filename=termin-{title}-{appointment_id}-{slot_start.replace(" ", "_").replace(":", "")}.ics'
return response return response
@appoint_bp.route('/export_pdf_brief/<plan_id>', methods=['GET'])
def export_pdf_brief(plan_id):
# 1. Daten holen (Hier als Beispiel, passe dies auf deine Datenbank an)
# terminplan = Terminplan.query.get(plan_id)
school_info = _get_school_info_for_export()
school_name = school_info.get('name', 'Schulname')
address = school_info.get('address', 'Adresse')
postal_code = school_info.get('postal_code', 'PLZ')
city = school_info.get('city', 'Stadt')
schul_daten = {
"schulname": school_name,
"strasse": address,
"plz_ort": f"{postal_code} {city}"
}
plan_daten = {
"titel": termin.get_item(plan_id).get('title', 'Terminplan'), # terminplan.title
"link": termin.get_item(plan_id).get('link', ''), # terminplan.link
"notizen": termin.get_item(plan_id).get('note', '') # terminplan.note
}
# 2. QR-Code Bild im temporären Ordner erstellen
qr = qrcode.QRCode(version=1, box_size=10, border=0)
qr.add_data(plan_daten["link"])
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
fd, qr_path = tempfile.mkstemp(suffix=".png")
os.close(fd)
img.save(qr_path)
# 3. PDF im Speicher aufbauen (BytesIO)
pdf_buffer = io.BytesIO()
doc = SimpleDocTemplate(
pdf_buffer,
pagesize=A4,
rightMargin=2*cm,
leftMargin=2.5*cm,
topMargin=2.5*cm,
bottomMargin=2*cm
)
styles = getSampleStyleSheet()
styles.add(ParagraphStyle(name='Sender', fontSize=8, textColor=grey))
styles.add(ParagraphStyle(name='Address', fontSize=10, leading=14))
styles.add(ParagraphStyle(name='Date', fontSize=10, alignment=2))
styles.add(ParagraphStyle(name='Subject', fontSize=14, fontName='Helvetica-Bold', spaceAfter=16, textColor=HexColor('#0f4c5c')))
styles.add(ParagraphStyle(name='Body', fontSize=11, leading=16, spaceAfter=12))
styles.add(ParagraphStyle(name='Notes', fontSize=10, leading=14, textColor=HexColor("#444444")))
elements = []
# Absenderzeile
sender_text = f"<u>{schul_daten['schulname']}{schul_daten['strasse']}{schul_daten['plz_ort']}</u>"
elements.append(Paragraph(sender_text, styles['Sender']))
elements.append(Spacer(1, 1.5*cm))
# Sichtfenster-Adresse (Generisch)
elements.append(Paragraph("An die<br/>Teilnehmerinnen und Teilnehmer<br/>des Termins", styles['Address']))
elements.append(Spacer(1, 2*cm))
# Datum (Hier statisch zum Test, ggf. dynamisch per datetime)
import datetime
heute = datetime.datetime.now().strftime("%d.%m.%Y")
elements.append(Paragraph(f"München, den {heute}", styles['Date']))
elements.append(Spacer(1, 1*cm))
# Betreff
elements.append(Paragraph(f"Einladung zur Terminbuchung: {plan_daten['titel']}", styles['Subject']))
# Text
elements.append(Paragraph("Sehr geehrte Damen und Herren,", styles['Body']))
elements.append(Paragraph("hiermit möchten wir Sie herzlich einladen, einen Termin für unsere anstehende Veranstaltung zu buchen. Um den Prozess für alle Beteiligten so einfach und effizient wie möglich zu gestalten, nutzen wir unser Online-Buchungssystem.", styles['Body']))
elements.append(Spacer(1, 0.5*cm))
# Link
elements.append(Paragraph("<b>Ihr persönlicher Buchungslink:</b>", styles['Body']))
link_html = f'<a href="{plan_daten["link"]}" color="#16697a">{plan_daten["link"]}</a>'
elements.append(Paragraph(link_html, styles['Body']))
# Das vorhin erstellte QR-Code Bild einfügen
elements.append(Spacer(1, 0.2*cm))
elements.append(Image(qr_path, width=3*cm, height=3*cm, hAlign='LEFT'))
elements.append(Spacer(1, 0.5*cm))
# Notizen (falls vorhanden)
if plan_daten.get('notizen'):
elements.append(Paragraph("<b>Zusätzliche Informationen zum Termin:</b>", styles['Body']))
elements.append(Paragraph(plan_daten['notizen'], styles['Notes']))
elements.append(Spacer(1, 1.5*cm))
# Grußformel
elements.append(Paragraph("Mit freundlichen Grüßen,", styles['Body']))
elements.append(Spacer(1, 1.5*cm))
elements.append(Paragraph(f"<b>{schul_daten['schulname']}</b>", styles['Body']))
# PDF fertigstellen
doc.build(elements)
# Temporäres Bild löschen
if os.path.exists(qr_path):
os.remove(qr_path)
# Buffer auf Anfang zurücksetzen
pdf_buffer.seek(0)
# An Nutzer ausliefern
return send_file(
pdf_buffer,
mimetype='application/pdf',
as_attachment=True,
download_name=f"Einladung_{plan_daten['titel'].replace(' ', '_')}.pdf"
)
@appoint_bp.route('/') @appoint_bp.route('/')
def main(): def main():
guard = _require_module_enabled() guard = _require_module_enabled()
@@ -382,6 +532,7 @@ def main():
upcoming_events = appointment_service.get_user_upcoming_events(current_user) if current_user else [] upcoming_events = appointment_service.get_user_upcoming_events(current_user) if current_user else []
tenant_id = _current_tenant_id() tenant_id = _current_tenant_id()
return render_template( return render_template(
'terminplaner.html', 'terminplaner.html',
school_periods=cfg.SCHOOL_PERIODS, school_periods=cfg.SCHOOL_PERIODS,
+15 -2
View File
@@ -82,7 +82,7 @@
</div> </div>
</div> </div>
<div id="custom-fields-container" class="mt-4"> <div id="custom-fields-container" class="mt-4">
<h3 class="mb-3">Custom Fields</h3> <h3 class="mb-3">Benutzerdefinierte Felder</h3>
<div class="mb-3 custom-field-row"> <div class="mb-3 custom-field-row">
<input type="text" <input type="text"
@@ -155,7 +155,7 @@
const slotLengthInput = document.getElementById('slot_length'); const slotLengthInput = document.getElementById('slot_length');
const clientsperslot = document.getElementById('clients_per_slot') const clientsperslot = document.getElementById('clients_per_slot')
const slotsAmountsInput = document.getElementById('slots_amounts'); const slotsAmountsInput = document.getElementById('slots_amounts');
const slotsAmountsDisplay = document.getElementById('slots_amounts_display'); // Neu: Anzeige-Element const slotsAmountsDisplay = document.getElementById('slots_amounts_display');
if (!startDateInput || !endDateInput || !buildButton || !daysContainer || !timeFrameTextarea) { if (!startDateInput || !endDateInput || !buildButton || !daysContainer || !timeFrameTextarea) {
return; return;
@@ -442,6 +442,19 @@
if (startDateInput.value && endDateInput.value) { if (startDateInput.value && endDateInput.value) {
renderRows(); renderRows();
} }
const configForm = document.querySelector('form');
if (configForm) {
configForm.addEventListener('keydown', function(event) {
if (event.key === 'Enter') {
if (event.target.tagName === 'TEXTAREA') return;
if (event.target.tagName === 'BUTTON' && event.target.type === 'submit') return;
event.preventDefault();
}
});
}
})(); })();
</script> </script>
{% endblock %} {% endblock %}
+45 -1
View File
@@ -6,6 +6,8 @@
<div class="container py-4"> <div class="container py-4">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-12 col-lg-11 col-xl-10"> <div class="col-12 col-lg-11 col-xl-10">
<!-- Hero Sektion -->
<section class="p-4 p-md-5 rounded-4 shadow-lg" style="background: linear-gradient(135deg, rgba(15,76,92,0.96), rgba(22,105,122,0.92)); color: #fff;"> <section class="p-4 p-md-5 rounded-4 shadow-lg" style="background: linear-gradient(135deg, rgba(15,76,92,0.96), rgba(22,105,122,0.92)); color: #fff;">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-4 align-items-start align-items-lg-end"> <div class="d-flex flex-column flex-lg-row justify-content-between gap-4 align-items-start align-items-lg-end">
<div> <div>
@@ -20,6 +22,39 @@
</div> </div>
</section> </section>
<!-- NEU EINGEBAUT: Erfolgsmeldung für gerade erstellte Buchungslinks -->
{% if generated_link %}
<div class="alert alert-success mt-4 shadow-sm rounded-4">
<div class="fw-bold mb-1">Buchungslink erstellt</div>
<div class="mb-2">
{% if mail_service_enabled %}
Teilen Sie diesen Link mit den Teilnehmenden oder versenden Sie ihn direkt per E-Mail.
{% else %}
Der E-Mail-Service ist deaktiviert. Teilen Sie diesen Link manuell mit den Teilnehmenden.
{% endif %}
</div>
<a href="{{ generated_link }}" class="d-inline-block text-break mb-3">{{ generated_link }}</a>
<!-- PDF Export per ReportLab -->
<div class="pt-3 border-top border-success-subtle">
<div class="fw-semibold mb-1">Einladungsbrief (inkl. QR-Code)</div>
<p class="small text-muted mb-2">Laden Sie einen automatisch generierten Brief herunter, der den Buchungslink und einen passenden QR-Code enthält.</p>
<a href="{{ url_for('terminplaner.export_pdf_brief', plan_id=plan_id) }}" class="btn btn-outline-success btn-sm">
📄 Brief als PDF herunterladen
</a>
</div>
{% if calendar_link %}
<div class="mt-3 pt-3 border-top border-success-subtle">
<div class="fw-semibold mb-1">Kalendereintrag</div>
<a href="{{ calendar_link }}" class="btn btn-outline-primary btn-sm">.ics herunterladen</a>
</div>
{% endif %}
</div>
{% endif %}
<!-- ENDE NEUER BLOCK -->
<!-- Info-Karten -->
<div class="row g-4 mt-1"> <div class="row g-4 mt-1">
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
<div class="card h-100 shadow-sm border-0 rounded-4"> <div class="card h-100 shadow-sm border-0 rounded-4">
@@ -59,6 +94,7 @@
<p class="mb-0 text-muted">Sie können Termine anlegen, den Kalender prüfen und Buchungslinks verteilen.</p> <p class="mb-0 text-muted">Sie können Termine anlegen, den Kalender prüfen und Buchungslinks verteilen.</p>
</div> </div>
<!-- "Tabelle" (Liste) der kommenden Termine -->
<div class="mt-4 p-4 rounded-4 bg-white shadow-sm"> <div class="mt-4 p-4 rounded-4 bg-white shadow-sm">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-3"> <div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-3">
<h2 class="h5 fw-bold mb-0">Kommende Termine</h2> <h2 class="h5 fw-bold mb-0">Kommende Termine</h2>
@@ -84,10 +120,18 @@
<div class="text-lg-end"> <div class="text-lg-end">
<div class="small mb-2">Gebucht: <strong>{{ event.slots_booked }}</strong> / {{ event.slots_total }} | Frei: <strong>{{ event.slots_left }}</strong></div> <div class="small mb-2">Gebucht: <strong>{{ event.slots_booked }}</strong> / {{ event.slots_total }} | Frei: <strong>{{ event.slots_left }}</strong></div>
<div class="d-flex flex-wrap gap-2 justify-content-lg-end"> <div class="d-flex flex-wrap gap-2 justify-content-lg-end">
<a class="btn btn-sm btn-primary" href="{{ event.link }}" target="_blank" rel="noopener">Client-Link öffnen</a>
<!-- Hinzugefügt: Direkter PDF-Export auch bei bestehenden Plänen -->
<a class="btn btn-sm btn-outline-success" href="{{ url_for('terminplaner.export_pdf_brief', plan_id=event.appointment_id) }}" title="Brief herunterladen">
📄 PDF
</a>
<a class="btn btn-sm btn-primary" href="{{ event.link }}" target="_blank" rel="noopener">Client-Link</a>
{% if event.calendar_link %} {% if event.calendar_link %}
<a class="btn btn-sm btn-outline-primary" href="{{ event.calendar_link }}">.ics</a> <a class="btn btn-sm btn-outline-primary" href="{{ event.calendar_link }}">.ics</a>
{% endif %} {% endif %}
<form method="post" action="{{ url_for('terminplaner.delete_appointment', appointment_id=event.appointment_id, tenant=tenant_id) }}" class="d-inline" onsubmit="return confirm('Diesen Terminplan wirklich löschen?');"> <form method="post" action="{{ url_for('terminplaner.delete_appointment', appointment_id=event.appointment_id, tenant=tenant_id) }}" class="d-inline" onsubmit="return confirm('Diesen Terminplan wirklich löschen?');">
<button type="submit" class="btn btn-sm btn-outline-danger">Entfernen</button> <button type="submit" class="btn btn-sm btn-outline-danger">Entfernen</button>
</form> </form>