Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9da4ff3ec8 | |||
| 884464cfe5 | |||
| 0be4e3ca58 | |||
| e2aeea46f9 | |||
| 865fddd45b | |||
| 1835195d2f | |||
| 19a585b2ec | |||
| 925f07e96f | |||
| b8beec8209 | |||
| dd9390c649 |
+149
-72
@@ -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()
|
||||||
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user