Compare commits

...

20 Commits

Author SHA1 Message Date
Aiirondev_dev 7e258e07ab some more savety processing 2026-06-27 13:28:49 +02:00
Aiirondev_dev 96245bb06b Fix of an legacy encryption Algorithm 2026-06-27 12:49:56 +02:00
Aiirondev_dev 245c3c19dd Fix of the SSRF Protection 2026-06-27 12:43:19 +02:00
Aiirondev_dev d76c69d836 SSRF Protection Implementation 2026-06-27 12:39:25 +02:00
Aiirondev_dev 9527fc10cf Fix of the Nav Bar for the Terminplaner Module 2026-06-27 11:31:47 +02:00
Aiirondev_dev 1122618a51 slight mispelled variable name 2026-06-27 11:05:35 +02:00
Aiirondev_dev 54d56fc463 link klorrektion 2026-06-27 10:53:28 +02:00
Aiirondev_dev 9da4ff3ec8 circuilar import fix 2026-06-27 10:08:12 +02:00
Aiirondev_dev 884464cfe5 pdf download for a client Brief 2026-06-27 10:00:10 +02:00
Aiirondev_dev 0be4e3ca58 return is beeing ignored to the submition 2026-06-27 00:35:44 +02:00
Aiirondev_dev e2aeea46f9 Fix of the encryption of when getting the upcoming events for a user 2026-06-27 00:16:59 +02:00
Aiirondev_dev 865fddd45b removal of unused encryption of user to compensate before right implementation of encryption 2026-06-27 00:08:41 +02:00
Aiirondev_dev 1835195d2f development debugging version 2026-06-26 23:58:15 +02:00
Aiirondev_dev 19a585b2ec Implementation of encryption for sensitive information that may be providet by the client, to ensure that all information important or not is safe 2026-06-26 23:39:05 +02:00
Aiirondev_dev 925f07e96f removal of development debugging 2026-06-26 22:00:53 +02:00
Aiirondev_dev b8beec8209 development Changes that will sort out the defect Link pasting in the Other Tab 2026-06-26 21:34:57 +02:00
Aiirondev_dev dd9390c649 fix of a slight mistake with the Link passing 2026-06-26 21:27:09 +02:00
Aiirondev_dev f31c4e2ff7 fix of misspelled atribute of a Arg 2026-06-26 21:18:50 +02:00
Aiirondev_dev 71e2895362 vital changes to the processing of the generation off a new appointment 2026-06-26 19:40:01 +02:00
Aiirondev_dev d2c7e57f8d changes to the Event lisseer and the Display of the booking range for the client 2026-06-26 19:37:04 +02:00
10 changed files with 586 additions and 271 deletions
+49 -47
View File
@@ -79,6 +79,7 @@ from Web.modules.inventarsystem.data_protection import (
decrypt_text, decrypt_text,
encrypt_document_fields, encrypt_document_fields,
encrypt_soft_deleted_media_pack, encrypt_soft_deleted_media_pack,
encrypt_text,
) )
from Web.modules.terminplaner.blueprint import appoint_bp as terminplaner_bp from Web.modules.terminplaner.blueprint import appoint_bp as terminplaner_bp
@@ -375,6 +376,18 @@ PERMISSION_ACTION_ENDPOINTS = {
'logs': 'can_view_logs', 'logs': 'can_view_logs',
} }
ALLOWED_COVER_DOMAINS = {
"books.google.com",
"covers.openlibrary.org",
"images-na.ssl-images-amazon.com",
"m.media-amazon.com",
"www.isbn.de",
"covers.openlibrary.org",
"openlibrary.org",
"lobid.org",
"www.googleapis.com"
}
# Apply the configuration for general use throughout the app # Apply the configuration for general use throughout the app
APP_VERSION = __version__ APP_VERSION = __version__
RELEASE_STATE_FILE = os.path.join(os.path.dirname(BASE_DIR), '.release-version') RELEASE_STATE_FILE = os.path.join(os.path.dirname(BASE_DIR), '.release-version')
@@ -657,7 +670,6 @@ def _get_current_module(path):
last_module = session.get('last_module') last_module = session.get('last_module')
if last_module and cfg.MODULES.is_enabled(last_module): if last_module and cfg.MODULES.is_enabled(last_module):
return last_module return last_module
if cfg.MODULES.is_enabled('inventory'): if cfg.MODULES.is_enabled('inventory'):
return 'inventory' return 'inventory'
return 'library' if cfg.MODULES.is_enabled('library') else 'inventory' return 'library' if cfg.MODULES.is_enabled('library') else 'inventory'
@@ -714,9 +726,9 @@ def _append_audit_event_standalone(event_type, payload):
al.append_audit_event( al.append_audit_event(
db=db, db=db,
event_type=event_type, event_type=event_type,
actor=session.get('username', 'system'), actor=encrypt_text(session.get('username', 'system')),
payload=payload, payload=encrypt_text(str(payload)),
request_ip=request.remote_addr, request_ip=encrypt_text(request.remote_addr),
source='web', source='web',
) )
except Exception as exc: except Exception as exc:
@@ -938,7 +950,7 @@ def _create_notification(db, *, audience, notif_type, title, message, target_use
tag=f'notification-{notif_type}' tag=f'notification-{notif_type}'
) )
except Exception as e: except Exception as e:
app.logger.warning(f'Failed to send push notification to {target_user}: {e}') app.logger.warning(f'Failed to send push notification to {encrypt_text(target_user)}: {e}')
elif audience == 'admin': elif audience == 'admin':
_bump_notification_version('admin') _bump_notification_version('admin')
# Send push notification to all admins # Send push notification to all admins
@@ -1041,7 +1053,7 @@ def _get_cached_unread_status(username, is_admin=False):
if cached: if cached:
return json.loads(cached), version_tag return json.loads(cached), version_tag
except Exception as exc: except Exception as exc:
app.logger.warning(f'Could not read notification status cache for {username}: {exc}') app.logger.warning(f'Could not read notification status cache for {encrypt_text(username)}: {exc}')
return None, version_tag return None, version_tag
now = time.time() now = time.time()
@@ -1064,7 +1076,7 @@ def _set_cached_unread_status(username, is_admin, version_tag, payload):
cache_client.setex(key, NOTIFICATION_STATUS_CACHE_TTL, json.dumps(payload, default=str)) cache_client.setex(key, NOTIFICATION_STATUS_CACHE_TTL, json.dumps(payload, default=str))
return return
except Exception as exc: except Exception as exc:
app.logger.warning(f'Could not write notification status cache for {username}: {exc}') app.logger.warning(f'Could not write notification status cache for {encrypt_text(username)}: {exc}')
with _NOTIFICATION_CACHE_LOCK: with _NOTIFICATION_CACHE_LOCK:
_NOTIFICATION_LOCAL_CACHE[key] = { _NOTIFICATION_LOCAL_CACHE[key] = {
@@ -2132,7 +2144,7 @@ def _upload_student_cards_excel():
created_total += 1 created_total += 1
except Exception as exc: except Exception as exc:
app.logger.error(f'Error importing student cards from Excel: {exc}') app.logger.error(f'Error importing student cards from Excel: {exc}')
flash(f'Fehler beim Import der Bibliotheksausweise: {exc}', 'error') flash(f'Fehler beim Import der Bibliotheksausweise', 'error')
return redirect(url_for('student_cards_admin')) return redirect(url_for('student_cards_admin'))
finally: finally:
client.close() client.close()
@@ -3599,7 +3611,6 @@ def api_item_detail(item_id):
""" """
client.close() client.close()
return detail_html, 200 return detail_html, 200
return detail_html, 200
except Exception as e: except Exception as e:
app.logger.error(f"Error fetching item detail: {e}") app.logger.error(f"Error fetching item detail: {e}")
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@@ -4409,19 +4420,17 @@ def login():
ctx = get_tenant_context() ctx = get_tenant_context()
current_tenant_id = ctx.tenant_id if ctx else None current_tenant_id = ctx.tenant_id if ctx else None
current_tenant_db = ctx.db_name if ctx else cfg.MONGODB_DB current_tenant_db = ctx.db_name if ctx else cfg.MONGODB_DB
app.logger.info(f"Login attempt: username={username!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={request.remote_addr}") app.logger.info(f"Login attempt: username={encrypt_text(username)!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={encrypt_text(request.remote_addr)}")
app.logger.info(f"Debug login context: headers={dict(request.headers)} tenant_config={ctx.config if ctx else None} remote_addr={request.remote_addr} host={request.host}")
app.logger.info(f"Raw login payload: username={username!r} password={password!r}")
app.logger.info(f"Active MongoDB config: uri={getattr(cfg, 'MONGODB_URI', None)!r} host={cfg.MONGODB_HOST!r} port={cfg.MONGODB_PORT!r} default_db={cfg.MONGODB_DB!r}") app.logger.info(f"Active MongoDB config: uri={getattr(cfg, 'MONGODB_URI', None)!r} host={cfg.MONGODB_HOST!r} port={cfg.MONGODB_PORT!r} default_db={cfg.MONGODB_DB!r}")
if not username or not password: if not username or not password:
app.logger.warning(f"Login blocked: missing credentials tenant={current_tenant_id or 'default'} host={request.host} ip={request.remote_addr}") app.logger.warning(f"Login blocked: missing credentials tenant={current_tenant_id or 'default'} host={request.host} ip={encrypt_text(request.remote_addr)}")
flash('Bitte alle Felder ausfüllen', 'error') flash('Bitte alle Felder ausfüllen', 'error')
return redirect(url_for('login')) return redirect(url_for('login'))
user = us.check_nm_pwd(username, password) user = us.check_nm_pwd(username, password)
if user: if user:
app.logger.info(f"Login success: username={username!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={request.remote_addr}") app.logger.info(f"Login success: username={encrypt_text(username)!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={encrypt_text(request.remote_addr)}")
session['username'] = username session['username'] = username
is_admin_user = bool(user.get('Admin', False)) is_admin_user = bool(user.get('Admin', False))
session['admin'] = is_admin_user session['admin'] = is_admin_user
@@ -4442,7 +4451,7 @@ def login():
else: else:
return redirect(url_for('home')) return redirect(url_for('home'))
else: else:
app.logger.warning(f"Login failed: username={username!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={request.remote_addr}") app.logger.warning(f"Login failed: username={encrypt_text(username)!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={encrypt_text(request.remote_addr)}")
flash('Ungültige Anmeldedaten', 'error') flash('Ungültige Anmeldedaten', 'error')
get_flashed_messages() get_flashed_messages()
return render_template('login.html') return render_template('login.html')
@@ -5106,7 +5115,7 @@ def upload_item():
# Log mobile request for debugging # Log mobile request for debugging
if is_mobile: if is_mobile:
app.logger.info(f"Mobile upload from {request.headers.get('User-Agent', 'unknown')} by {username}") app.logger.info(f"Mobile upload from {request.headers.get('User-Agent', 'unknown')} by {encrypt_text(username)}")
try: try:
# Strip whitespace from all text fields # Strip whitespace from all text fields
@@ -5339,7 +5348,7 @@ def upload_item():
# Create a structured log entry for upload session # Create a structured log entry for upload session
upload_session_id = str(uuid.uuid4())[:8] upload_session_id = str(uuid.uuid4())[:8]
app.logger.info(f"Starting image upload session {upload_session_id} - Files: {len(images)}, User: {username}") app.logger.info(f"Starting image upload session {upload_session_id} - Files: {len(images)}, User: {encrypt_text(username)}")
# Ensure all required directories exist # Ensure all required directories exist
for directory in [app.config['UPLOAD_FOLDER']]: for directory in [app.config['UPLOAD_FOLDER']]:
@@ -6093,7 +6102,7 @@ def duplicate_item():
# Log mobile duplication for debugging # Log mobile duplication for debugging
if is_mobile: if is_mobile:
app.logger.info(f"Mobile duplication from {request.headers.get('User-Agent', 'unknown')} by {username}") app.logger.info(f"Mobile duplication from {request.headers.get('User-Agent', 'unknown')} by {encrypt_text(username)}")
# Get original item ID # Get original item ID
original_item_id = request.form.get('original_item_id') original_item_id = request.form.get('original_item_id')
@@ -9737,9 +9746,6 @@ def fetch_book_info(isbn):
def download_book_cover(): def download_book_cover():
""" """
API endpoint to download and save a book cover image from URL API endpoint to download and save a book cover image from URL
Returns:
dict: Success status and filename or error message
""" """
if 'username' not in session: if 'username' not in session:
return jsonify({"error": "Not authorized"}), 403 return jsonify({"error": "Not authorized"}), 403
@@ -9759,17 +9765,17 @@ def download_book_cover():
if parsed_url.scheme != 'https' or not parsed_url.netloc: if parsed_url.scheme != 'https' or not parsed_url.netloc:
return jsonify({"error": "Only public HTTPS URLs are allowed"}), 400 return jsonify({"error": "Only public HTTPS URLs are allowed"}), 400
hostname = parsed_url.hostname or '' # 2. SSRF Protection: Strict Allowlist Check
if not _is_public_host(hostname): if parsed_url.netloc not in ALLOWED_COVER_DOMAINS:
return jsonify({"error": "Target host is not allowed"}), 400 return jsonify({"error": "Target host is not an allowed book cover provider"}), 403
# Download the image # Download the image (allow_redirects=False prevents redirecting to internal IPs)
response = requests.get(image_url, stream=True, timeout=10, allow_redirects=False) response = requests.get(image_url, stream=True, timeout=10, allow_redirects=False)
if response.status_code != 200: if response.status_code != 200:
return jsonify({"error": f"Failed to download image: Status {response.status_code}"}), 400 return jsonify({"error": f"Failed to download image: Status {response.status_code}"}), 400
# Check content type to ensure it's an image of allowed format # Check content type
content_type = response.headers.get('content-type', '') content_type = response.headers.get('content-type', '')
allowed_types = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'] allowed_types = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif']
@@ -9778,6 +9784,7 @@ def download_book_cover():
"error": f"Nicht unterstütztes Bildformat: {content_type}. Erlaubte Formate: JPG, JPEG, PNG, GIF" "error": f"Nicht unterstütztes Bildformat: {content_type}. Erlaubte Formate: JPG, JPEG, PNG, GIF"
}), 400 }), 400
# Check content length header
content_length = response.headers.get('Content-Length') content_length = response.headers.get('Content-Length')
if content_length: if content_length:
try: try:
@@ -9786,14 +9793,10 @@ def download_book_cover():
except ValueError: except ValueError:
pass pass
# Generate a fully unique filename using UUID # Generate a fully unique filename
import uuid
import time
unique_id = str(uuid.uuid4()) unique_id = str(uuid.uuid4())
timestamp = time.strftime("%Y%m%d%H%M%S") timestamp = time.strftime("%Y%m%d%H%M%S")
# Use appropriate extension based on content type
extension = '.jpg' # default extension = '.jpg' # default
if 'image/png' in content_type.lower(): if 'image/png' in content_type.lower():
extension = '.png' extension = '.png'
@@ -9801,15 +9804,16 @@ def download_book_cover():
extension = '.gif' extension = '.gif'
filename = f"book_cover_{unique_id}_{timestamp}{extension}" filename = f"book_cover_{unique_id}_{timestamp}{extension}"
# Save the image to uploads folder
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename) filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
# Save image in chunks (prevents memory exhaustion and enforces size limits)
with open(filepath, 'wb') as f: with open(filepath, 'wb') as f:
written = 0 written = 0
for chunk in response.iter_content(chunk_size=8192): for chunk in response.iter_content(chunk_size=8192):
written += len(chunk) written += len(chunk)
if written > 5 * 1024 * 1024: if written > 5 * 1024 * 1024:
# Clean up the partial file before aborting
os.remove(filepath)
return jsonify({"error": "Image is too large"}), 413 return jsonify({"error": "Image is too large"}), 413
f.write(chunk) f.write(chunk)
@@ -9819,9 +9823,12 @@ def download_book_cover():
"message": "Image downloaded successfully" "message": "Image downloaded successfully"
}) })
except requests.exceptions.RequestException as e:
print(f"Network error downloading book cover: {e}")
return jsonify({"error": "Netzwerkfehler beim Herunterladen des Bildes."}), 500
except Exception as e: except Exception as e:
print(f"Error downloading book cover: {e}") print(f"Error downloading book cover: {e}")
# Fixed syntax here: Removed the injected HTML that was appended to this line
return jsonify({"error": f"Failed to download image: {str(e)}"}), 500 return jsonify({"error": f"Failed to download image: {str(e)}"}), 500
""" """
@app.route('/proxy_image') @app.route('/proxy_image')
@@ -9978,11 +9985,6 @@ def my_borrowed_items():
'Status': 'planned' 'Status': 'planned'
})) }))
# DEBUG: Log the number of planned appointments found
app.logger.info(f"Found {len(planned_ausleihungen)} planned appointments for user {username}")
for appt in planned_ausleihungen:
app.logger.info(f"Planned appointment: ID={str(appt['_id'])}, Item={str(appt.get('Item'))}, Start={appt.get('Start')}")
# Process items # Process items
active_items = [] active_items = []
planned_items = [] planned_items = []
@@ -10188,7 +10190,7 @@ def mark_all_notifications_read():
if result.modified_count > 0: if result.modified_count > 0:
_bump_notification_version(f'user:{username}') _bump_notification_version(f'user:{username}')
except Exception as exc: except Exception as exc:
app.logger.warning(f"Could not mark all notifications as read for {username}: {exc}") app.logger.warning(f"Could not mark all notifications as read for {encrypt_text(username)}: {exc}")
finally: finally:
if client: if client:
client.close() client.close()
@@ -10267,7 +10269,7 @@ def notifications_unread_status():
etag_value = _build_unread_status_etag(version_tag, payload) etag_value = _build_unread_status_etag(version_tag, payload)
return _build_cached_json_response(payload, etag_value) return _build_cached_json_response(payload, etag_value)
except Exception as exc: except Exception as exc:
app.logger.warning(f"Could not fetch unread notification status for {username}: {exc}") app.logger.warning(f"Could not fetch unread notification status for {encrypt_text(username)}: {exc}")
return jsonify({'ok': False, 'error': 'status_fetch_failed'}), 500 return jsonify({'ok': False, 'error': 'status_fetch_failed'}), 500
finally: finally:
if client: if client:
@@ -11563,10 +11565,10 @@ def log_mobile_action(action, request, success=True, details=None):
if details: if details:
message += f" - Details: {details}" message += f" - Details: {details}"
if success: # if success:
app.logger.info(message) # app.logger.info(message)
else: # else:
app.logger.error(message) # app.logger.error(message)
# Add explicit static file routes to handle CSS serving issues # Add explicit static file routes to handle CSS serving issues
@app.route('/static/<path:filename>') @app.route('/static/<path:filename>')
@@ -11848,7 +11850,7 @@ def subscribe_to_push():
success = pn.save_push_subscription(username, subscription) success = pn.save_push_subscription(username, subscription)
if success: if success:
app.logger.info(f'Push subscription saved for {username}') app.logger.info(f'Push subscription saved for {encrypt_text(username)}')
return jsonify({ return jsonify({
'success': True, 'success': True,
'message': 'Successfully subscribed to push notifications' 'message': 'Successfully subscribed to push notifications'
@@ -11885,7 +11887,7 @@ def unsubscribe_from_push():
success = pn.remove_push_subscription(username, endpoint) success = pn.remove_push_subscription(username, endpoint)
if success: if success:
app.logger.info(f'Push subscription removed for {username}') app.logger.info(f'Push subscription removed for {encrypt_text(username)}')
return jsonify({ return jsonify({
'success': True, 'success': True,
'message': 'Successfully unsubscribed from push notifications' 'message': 'Successfully unsubscribed from push notifications'
+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()
+2 -3
View File
@@ -21,7 +21,7 @@ def log_status_change(ausleihung_id, old_status, new_status, user=None):
ausleihung_id: Die ID der Ausleihung ausleihung_id: Die ID der Ausleihung
old_status: Der alte Status old_status: Der alte Status
new_status: Der neue Status new_status: Der neue Status
user: Der Benutzer, der die Änderung vorgenommen hat (optional)
""" """
try: try:
# Erstelle Log-Verzeichnis, falls es nicht existiert # Erstelle Log-Verzeichnis, falls es nicht existiert
@@ -34,10 +34,9 @@ def log_status_change(ausleihung_id, old_status, new_status, user=None):
# Protokolliere die Änderung # Protokolliere die Änderung
timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
user_info = f" by {user}" if user else ""
with open(log_file, 'a', encoding='utf-8') as f: with open(log_file, 'a', encoding='utf-8') as f:
f.write(f"{timestamp}: Ausleihung {ausleihung_id} - Status changed from '{old_status}' to '{new_status}'{user_info}\n") f.write(f"{timestamp}: Ausleihung {ausleihung_id} - Status changed from '{old_status}' to '{new_status}'\n")
return True return True
except Exception as e: except Exception as e:
+11 -9
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')
@@ -198,7 +201,7 @@ def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str
return '\r\n'.join(ics_lines) return '\r\n'.join(ics_lines)
def new(date_start: str, date_end: str, time_span: list, slots, slot_length, user: str, mail: list=None, note:str="", calendar_enabled: bool=False, title: str="", custom_fields: list = (), client_per_slot: int=1) -> dict: def new(date_start: str, date_end: str, time_span: list, slots, slot_length, user: str, mail: list=None, note:str="", calendar_enabled: bool=False, title: str="", custom_fields: list = (), clients_per_slot: int=1) -> dict:
""" """
Generates a link for the executive to send to his clients to book a time Slot Generates a link for the executive to send to his clients to book a time Slot
""" """
@@ -220,7 +223,7 @@ def new(date_start: str, date_end: str, time_span: list, slots, slot_length, use
normalized_time_span = _normalize_time_span(time_span) normalized_time_span = _normalize_time_span(time_span)
normalized_mail = _normalize_mail_list(mail or []) normalized_mail = _normalize_mail_list(mail or [])
id = termin.add(date_start, date_end, normalized_time_span, slots_int, slot_length_int, user, normalized_mail, note, calendar_enabled=calendar_enabled, title=title, custom_fields=custom_fields, clients_p_slot=client_per_slot) id = termin.add(date_start, date_end, normalized_time_span, slots_int, slot_length_int, user, normalized_mail, note, calendar_enabled=calendar_enabled, title=title, custom_fields=custom_fields, clients_p_slot=clients_per_slot)
id_str = str(id) id_str = str(id)
tenant_id = _current_tenant_id() tenant_id = _current_tenant_id()
@@ -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:
+187 -17
View File
@@ -1,17 +1,57 @@
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
from Web.modules.terminplaner.backend_server import _resolve_public_base_url
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')
@@ -175,7 +215,8 @@ def client(appointment_id):
current_user=session.get('username', ''), current_user=session.get('username', ''),
tenant_id=_current_tenant_id(), tenant_id=_current_tenant_id(),
can_view_booking_names=can_view_booking_names, can_view_booking_names=can_view_booking_names,
custom_fields=custom_fields custom_fields=custom_fields,
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan')
) )
if appointment_service.book_slot(appointment_id, start_daytime, username, custom=custom_answers): if appointment_service.book_slot(appointment_id, start_daytime, username, custom=custom_answers):
@@ -199,7 +240,8 @@ def client(appointment_id):
tenant_id=_current_tenant_id(), tenant_id=_current_tenant_id(),
can_view_booking_names=can_view_booking_names, can_view_booking_names=can_view_booking_names,
custom_fields=custom_fields, custom_fields=custom_fields,
appointment_item=appointment_item appointment_item=appointment_item,
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan')
) )
@@ -293,37 +335,30 @@ def configure():
date_end=end, date_end=end,
time_span=time, time_span=time,
slots=slots_amount, slots=slots_amount,
slot_lenght=slot_length, slot_length=slot_length,
user=session["username"], user=session["username"],
mail=mail, mail=mail,
note=note, note=note,
calendar_enabled=add_to_calendar, calendar_enabled=add_to_calendar,
title=title, title=title,
custom_fields=custom, custom_fields=custom,
clients_p_slot=clients_p_slot clients_per_slot=clients_p_slot
) )
if not inserted_id: if not inserted_id:
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,
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan')
) )
return render_template( return render_template(
@@ -334,6 +369,7 @@ def configure():
add_to_calendar=False, add_to_calendar=False,
email_service_enabled=cfg.EMAIL_ENABLED, email_service_enabled=cfg.EMAIL_ENABLED,
title=None, title=None,
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan')
) )
@@ -372,6 +408,138 @@ 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')
school_number = school_info.get('school_number', '000000')
it_admin = school_info.get('it_admin', 'IT-Beauftragter/in')
tenant_id = _current_tenant_id()
try:
link = url_for('terminplaner.client', appointment_id=plan_id, tenant=tenant_id or None, _external=True)
except Exception:
host = _resolve_public_base_url()
link = host + "/terminplaner/client/" + plan_id
if tenant_id:
link += f"?tenant={tenant_id}"
schul_daten = {
"schulname": school_name,
"strasse": address,
"plz_ort": f"{postal_code} {city}",
"schulnummer": school_number,
"it_admin": it_admin
}
plan_daten = {
"titel": termin.get_item(plan_id).get('title', 'Terminplan'), # terminplan.title
"link": 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"{schul_daten['plz_ort']}, 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,10 +550,12 @@ 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,
current_user=current_user, current_user=current_user,
upcoming_events=upcoming_events, upcoming_events=upcoming_events,
tenant_id=tenant_id, tenant_id=tenant_id,
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan')
) )
+70 -109
View File
@@ -13,6 +13,7 @@ import logging
import Web.modules.database.settings as cfg import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient from Web.modules.database.settings import MongoClient
from Web.modules.inventarsystem.data_protection import encrypt_text, decrypt_text
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -44,16 +45,22 @@ if not VAPID_PUBLIC_KEY or not VAPID_PRIVATE_KEY:
serialization.PublicFormat.UncompressedPoint serialization.PublicFormat.UncompressedPoint
) )
VAPID_PUBLIC_KEY = b64urlencode(raw_pub) VAPID_PUBLIC_KEY = b64urlencode(raw_pub).decode('utf-8')
VAPID_PRIVATE_KEY = VAPID_PRIVATE_PEM VAPID_PRIVATE_KEY = VAPID_PRIVATE_PEM
except Exception as e: except Exception as e:
logger.error(f'Could not load or generate VAPID keys: {e}') logger.error(f'Could not load or generate VAPID keys: {e}')
# Push service endpoint (typically Firebase or Web Push Service) # Push service endpoint (typically Firebase or Web Push Service)
PUSH_SERVICE_URL = 'https://fcm.googleapis.com/fcm/send' # Firebase Cloud Messaging
FCM_API_KEY = os.getenv('FCM_API_KEY', '') # Firebase API key FCM_API_KEY = os.getenv('FCM_API_KEY', '') # Firebase API key
def _get_username_hash(username):
"""Generates a deterministic hash for database lookups."""
if not username:
return None
return hashlib.sha256(username.encode('utf-8')).hexdigest()
def get_push_subscriptions_collection(db=None): def get_push_subscriptions_collection(db=None):
"""Get MongoDB push subscriptions collection""" """Get MongoDB push subscriptions collection"""
if db is None: if db is None:
@@ -64,71 +71,68 @@ def get_push_subscriptions_collection(db=None):
def get_user_subscriptions(username): def get_user_subscriptions(username):
""" """
Get all active push subscriptions for a user Get all active push subscriptions for a user, decrypting data on the fly.
Args:
username (str): Username
Returns:
list: List of subscription documents
""" """
try: try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
subs_col = get_push_subscriptions_collection(db) subs_col = get_push_subscriptions_collection(db)
subscriptions = list(subs_col.find({ # Query using the deterministic hash, NOT the encrypted text directly
'Username': username, user_hash = _get_username_hash(username)
encrypted_subscriptions = list(subs_col.find({
'UsernameHash': user_hash,
'IsActive': True 'IsActive': True
})) }))
client.close() client.close()
return subscriptions
# Decrypt endpoints and keys before returning
decrypted_subs = []
for sub in encrypted_subscriptions:
try:
sub['Endpoint'] = decrypt_text(sub.get('Endpoint'))
# Keys are stored as encrypted JSON strings
decrypted_keys_str = decrypt_text(sub.get('Keys'))
sub['Keys'] = json.loads(decrypted_keys_str) if decrypted_keys_str else {}
decrypted_subs.append(sub)
except Exception as e: except Exception as e:
logger.error(f'Error getting push subscriptions for {username}: {e}') logger.error(f"Failed to decrypt subscription payload for hash {user_hash}: {e}")
return decrypted_subs
except Exception as e:
logger.error(f'Error getting push subscriptions for user: {e}')
return [] return []
def save_push_subscription(username, subscription_obj): def save_push_subscription(username, subscription_obj):
""" """
Save a new push subscription for a user Save a new push subscription for a user with field-level encryption.
Args:
username (str): Username
subscription_obj (dict): Subscription object from Service Worker
{
'endpoint': 'https://...',
'keys': {
'p256dh': '...',
'auth': '...'
}
}
Returns:
bool: Success status
""" """
try: try:
if not subscription_obj.get('endpoint'): endpoint = subscription_obj.get('endpoint')
logger.warning(f'Invalid subscription object for {username}') if not endpoint:
logger.warning('Invalid subscription object: missing endpoint')
return False return False
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
subs_col = get_push_subscriptions_collection(db) subs_col = get_push_subscriptions_collection(db)
# Create unique hash of subscription to avoid duplicates # Create unique hash of subscription using plaintext data to avoid duplicates
sub_hash = hashlib.md5( sub_hash = hashlib.shake_256(
f"{username}:{subscription_obj['endpoint']}".encode() f"{username}:{endpoint}".encode('utf-8')
).hexdigest() ).hexdigest()
# Check if subscription already exists # Check if subscription already exists by Hash
existing = subs_col.find_one({ existing = subs_col.find_one({
'Username': username,
'SubscriptionHash': sub_hash 'SubscriptionHash': sub_hash
}) })
if existing: if existing:
# Update last used time
subs_col.update_one( subs_col.update_one(
{'_id': existing['_id']}, {'_id': existing['_id']},
{'$set': { {'$set': {
@@ -136,15 +140,19 @@ def save_push_subscription(username, subscription_obj):
'IsActive': True 'IsActive': True
}} }}
) )
logger.info(f'Updated existing subscription for {username}') logger.info('Updated existing push subscription')
client.close() client.close()
return True return True
# Save new subscription # Format keys as JSON string for your encrypt_text module
keys_str = json.dumps(subscription_obj.get('keys', {}))
# Save new subscription, encrypting sensitive fields
subscription_doc = { subscription_doc = {
'Username': username, 'UsernameHash': _get_username_hash(username),
'Endpoint': subscription_obj['endpoint'], 'Username': encrypt_text(username),
'Keys': subscription_obj.get('keys', {}), 'Endpoint': encrypt_text(endpoint),
'Keys': encrypt_text(keys_str),
'SubscriptionHash': sub_hash, 'SubscriptionHash': sub_hash,
'IsActive': True, 'IsActive': True,
'CreatedAt': datetime.datetime.now(), 'CreatedAt': datetime.datetime.now(),
@@ -153,36 +161,31 @@ def save_push_subscription(username, subscription_obj):
} }
subs_col.insert_one(subscription_doc) subs_col.insert_one(subscription_doc)
logger.info(f'Saved new push subscription for {username}') logger.info('Saved new encrypted push subscription')
client.close() client.close()
return True return True
except Exception as e: except Exception as e:
logger.error(f'Error saving push subscription for {username}: {e}') logger.error(f'Error saving push subscription: {e}')
return False return False
def remove_push_subscription(username, endpoint): def remove_push_subscription(username, endpoint):
""" """
Remove a push subscription Remove a push subscription by making it inactive.
Args:
username (str): Username
endpoint (str): Subscription endpoint URL
Returns:
bool: Success status
""" """
try: try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
subs_col = get_push_subscriptions_collection(db) subs_col = get_push_subscriptions_collection(db)
# Recreate the deterministic hash to find the specific subscription
sub_hash = hashlib.shake_256(
f"{username}:{endpoint}".encode('utf-8')
).hexdigest()
result = subs_col.update_one( result = subs_col.update_one(
{ {'SubscriptionHash': sub_hash},
'Username': username,
'Endpoint': endpoint
},
{'$set': {'IsActive': False}} {'$set': {'IsActive': False}}
) )
@@ -190,31 +193,19 @@ def remove_push_subscription(username, endpoint):
return result.modified_count > 0 return result.modified_count > 0
except Exception as e: except Exception as e:
logger.error(f'Error removing push subscription for {username}: {e}') logger.error(f'Error removing push subscription: {e}')
return False return False
def send_push_notification(username, title, body, icon=None, url='/', reference=None, tag='notification'): def send_push_notification(username, title, body, icon=None, url='/', reference=None, tag='notification'):
""" """
Send a push notification to all user's subscriptions Send a push notification to all user's subscriptions.
Args:
username (str): Target username
title (str): Notification title
body (str): Notification body
icon (str, optional): Icon URL
url (str, optional): URL to open on click
reference (dict, optional): Reference data (item_id, etc)
tag (str, optional): Notification tag for grouping
Returns:
int: Number of successfully sent notifications
""" """
try: try:
subscriptions = get_user_subscriptions(username) subscriptions = get_user_subscriptions(username)
if not subscriptions: if not subscriptions:
logger.debug(f'No active push subscriptions for {username}') logger.debug('No active push subscriptions for user')
return 0 return 0
sent_count = 0 sent_count = 0
@@ -232,19 +223,18 @@ def send_push_notification(username, title, body, icon=None, url='/', reference=
if success: if success:
sent_count += 1 sent_count += 1
else: else:
# Mark subscription as inactive if send fails
_mark_subscription_inactive(subscription['_id']) _mark_subscription_inactive(subscription['_id'])
logger.info(f'Sent push notification to {username}: {sent_count}/{len(subscriptions)} subscriptions') logger.info(f'Sent push notification: {sent_count}/{len(subscriptions)} subscriptions')
return sent_count return sent_count
except Exception as e: except Exception as e:
logger.error(f'Error sending push notification to {username}: {e}') logger.error(f'Error sending push notification: {e}')
return 0 return 0
def _send_to_subscription(subscription, title, body, icon, url, reference, tag): def _send_to_subscription(subscription, title, body, icon, url, reference, tag):
"""Send push notification to a specific subscription""" """Send push notification to a specific decrypted subscription"""
try: try:
payload = { payload = {
'title': title, 'title': title,
@@ -256,20 +246,17 @@ def _send_to_subscription(subscription, title, body, icon, url, reference, tag):
'reference': reference or {}, 'reference': reference or {},
} }
# If using Firebase Cloud Messaging
if FCM_API_KEY and subscription.get('Endpoint', '').startswith('https://fcm.'): if FCM_API_KEY and subscription.get('Endpoint', '').startswith('https://fcm.'):
return _send_fcm_notification(subscription, payload) return _send_fcm_notification(subscription, payload)
# Otherwise use standard Web Push Protocol
return _send_web_push_notification(subscription, payload) return _send_web_push_notification(subscription, payload)
except Exception as e: except Exception as e:
logger.error(f'Error sending to subscription {subscription.get("_id")}: {e}') logger.error(f'Error sending to subscription: {e}')
return False return False
def _send_fcm_notification(subscription, payload): def _send_fcm_notification(subscription, payload):
"""Send notification via Firebase Cloud Messaging"""
try: try:
if not FCM_API_KEY: if not FCM_API_KEY:
logger.warning('FCM_API_KEY not configured') logger.warning('FCM_API_KEY not configured')
@@ -311,9 +298,7 @@ def _send_fcm_notification(subscription, payload):
def _send_web_push_notification(subscription, payload): def _send_web_push_notification(subscription, payload):
"""Send notification using standard Web Push Protocol"""
try: try:
# This requires pywebpush library
from pywebpush import webpush from pywebpush import webpush
webpush( webpush(
@@ -325,13 +310,13 @@ def _send_web_push_notification(subscription, payload):
vapid_private_key=VAPID_PRIVATE_KEY, vapid_private_key=VAPID_PRIVATE_KEY,
vapid_claims={'sub': VAPID_SUBJECT}, vapid_claims={'sub': VAPID_SUBJECT},
timeout=10, timeout=10,
ttl=3600 # Notification expires after 1 hour if device is offline ttl=3600
) )
return True return True
except ImportError: except ImportError:
logger.warning('pywebpush not installed, install with: pip install pywebpush') logger.warning('pywebpush not installed. pip install pywebpush')
return False return False
except Exception as e: except Exception as e:
logger.error(f'Web push error: {e}') logger.error(f'Web push error: {e}')
@@ -339,7 +324,6 @@ def _send_web_push_notification(subscription, payload):
def _mark_subscription_inactive(subscription_id): def _mark_subscription_inactive(subscription_id):
"""Mark a subscription as inactive (e.g., after failed send)"""
try: try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
@@ -349,37 +333,21 @@ def _mark_subscription_inactive(subscription_id):
{'_id': ObjectId(subscription_id)}, {'_id': ObjectId(subscription_id)},
{'$set': {'IsActive': False}} {'$set': {'IsActive': False}}
) )
client.close() client.close()
except Exception as e: except Exception as e:
logger.error(f'Error marking subscription inactive: {e}') logger.error(f'Error marking subscription inactive: {e}')
def send_push_to_all_admins(title, body, icon=None, url='/', reference=None): def send_push_to_all_admins(title, body, icon=None, url='/', reference=None):
"""
Send a push notification to all admin users
Args:
title (str): Notification title
body (str): Notification body
icon (str, optional): Icon URL
url (str, optional): URL to open on click
reference (dict, optional): Reference data
Returns:
int: Total notifications sent
"""
try: try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
users_col = db['users'] users_col = db['users']
# Get all admin users
admin_users = list(users_col.find( admin_users = list(users_col.find(
{'Admin': True}, {'Admin': True},
{'Username': 1} {'Username': 1}
)) ))
client.close() client.close()
total_sent = 0 total_sent = 0
@@ -404,10 +372,6 @@ def send_push_to_all_admins(title, body, icon=None, url='/', reference=None):
def cleanup_inactive_subscriptions(): def cleanup_inactive_subscriptions():
"""
Remove inactive subscriptions older than 30 days
Run this periodically as a maintenance task
"""
try: try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
@@ -429,18 +393,15 @@ def cleanup_inactive_subscriptions():
return 0 return 0
# Database collection schema
def ensure_push_subscriptions_collection(): def ensure_push_subscriptions_collection():
"""Ensure the push_subscriptions collection exists with proper indexes"""
try: try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
subs_col = get_push_subscriptions_collection(db) subs_col = get_push_subscriptions_collection(db)
# Create indexes subs_col.create_index('UsernameHash')
subs_col.create_index('Username') subs_col.create_index([('UsernameHash', 1), ('IsActive', 1)])
subs_col.create_index([('Username', 1), ('IsActive', 1)]) subs_col.create_index([('CreatedAt', 1)])
subs_col.create_index([('CreatedAt', 1)]) # TTL-like usage
subs_col.create_index('SubscriptionHash', unique=True) subs_col.create_index('SubscriptionHash', unique=True)
logger.info('Push subscriptions collection indexes created') logger.info('Push subscriptions collection indexes created')
+47
View File
@@ -1144,6 +1144,17 @@
<li class="nav-item dropdown ms-lg-auto"> <li class="nav-item dropdown ms-lg-auto">
<a class="nav-link dropdown-toggle" href="#" id="termMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">Mehr Optionen</a> <a class="nav-link dropdown-toggle" href="#" id="termMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">Mehr Optionen</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="termMoreDropdown"> <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="termMoreDropdown">
{% if 'username' in session %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
{% endif %}
<li><a class="dropdown-item" href="{{ url_for('admin_school_settings') }}">Schulstammdaten</a></li>
{% if current_permissions.actions.get('can_view_logs', True) and current_permissions.pages.get('admin_audit_dashboard', True) %}
<li><a class="dropdown-item" href="{{ url_for('admin_audit_dashboard') }}">Audit Dashboard</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
{% endif %}
{% if current_permissions.pages.get('home', True) %} {% if current_permissions.pages.get('home', True) %}
<li><a class="dropdown-item" href="{{ url_for('home') }}">Inventarsystem</a></li> <li><a class="dropdown-item" href="{{ url_for('home') }}">Inventarsystem</a></li>
{% endif %} {% endif %}
@@ -1155,6 +1166,42 @@
</ul> </ul>
</li> </li>
</ul> </ul>
<div class="d-flex">
{% if 'username' in session %}
<div class="function-search-wrap">
<form class="function-search-form" data-function-search="true">
<input
class="function-search-input"
type="search"
name="function_search"
placeholder="Funktion suchen..."
list="function-search-options"
autocomplete="off"
>
<button class="function-search-btn" type="submit">Los</button>
</form>
</div>
{% if current_tenant_db %}
<span class="navbar-text tenant-badge" title="Aktive Tenant-Datenbank">{{ current_tenant_db }}</span>
{% endif %}
<span class="navbar-text text-light me-3">{{ session['username'] }}</span>
<div class="dropdown me-2 user-menu-wrap">
<button class="btn btn-secondary dropdown-toggle user-menu-btn" type="button" id="invUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false" data-notification-button="true">
👤
<span class="user-notification-dot {% if unread_notification_count and unread_notification_count > 0 %}visible{% endif %}" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invUserMenuDropdown">
{% if current_permissions.pages.get('notifications_view', True) %}
<li><a class="dropdown-item" href="{{ url_for('notifications_view') }}">Benachrichtigungen</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
</ul>
</div>
{% endif %}
</div>
</div> </div>
</div> </div>
</nav> </nav>
+1 -1
View File
@@ -452,7 +452,7 @@ document.addEventListener('DOMContentLoaded', function () {
const slotStartSet = new Set(candidateSlots.map(function (slot) { return slot.start; })); const slotStartSet = new Set(candidateSlots.map(function (slot) { return slot.start; }));
const allDays = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || '')); const allDays = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
let slotMinTime = '08:15:00'; let slotMinTime = '08:00:00';
let slotMaxTime = '20:00:00'; let slotMaxTime = '20:00:00';
const visibleStart = allDays[0] || String(available.date_start || ''); const visibleStart = allDays[0] || String(available.date_start || '');
+17 -4
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;
@@ -435,13 +435,26 @@
slotLengthInput.addEventListener('change', calculateSlots); slotLengthInput.addEventListener('change', calculateSlots);
} }
if (clientsperslot) { if (clientsperslot) {
slotLengthInput.addEventListener('input', calculateSlots); clientsperslot.addEventListener('input', calculateSlots);
slotLengthInput.addEventListener('change', calculateSlots); clientsperslot.addEventListener('change', calculateSlots);
} }
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>