Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94326728eb | |||
| efe17883a6 | |||
| 76f93b3d2e | |||
| 8f793045c2 | |||
| a199439d76 | |||
| 6cec483390 | |||
| 10e2f7cc17 | |||
| 0f8c5a0fec | |||
| 091147ecfd | |||
| 7bedfc1558 | |||
| 7e258e07ab | |||
| 96245bb06b | |||
| 245c3c19dd | |||
| d76c69d836 | |||
| 9527fc10cf | |||
| 1122618a51 | |||
| 54d56fc463 | |||
| 9da4ff3ec8 | |||
| 884464cfe5 | |||
| 0be4e3ca58 | |||
| e2aeea46f9 | |||
| 865fddd45b | |||
| 1835195d2f | |||
| 19a585b2ec | |||
| 925f07e96f | |||
| b8beec8209 | |||
| dd9390c649 | |||
| f31c4e2ff7 | |||
| 71e2895362 | |||
| d2c7e57f8d | |||
| 111d40b787 | |||
| 1a9513d442 | |||
| 3900059eb1 | |||
| 07b1cc5e79 | |||
| 6583f7a368 |
+163
-175
@@ -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,20 @@ 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
SENSITIVE_AUDIT_FIELDS = ["email", "username", "full_name", "phone", "borrower", "ip"]
|
||||||
|
|
||||||
# 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')
|
||||||
@@ -645,7 +660,7 @@ def _csrf_error_response(message='CSRF token fehlt oder ist ungültig.'):
|
|||||||
if request.is_json or request.path.startswith('/api/') or request.path in {'/download_book_cover', '/proxy_image', '/log_mobile_issue'}:
|
if request.is_json or request.path.startswith('/api/') or request.path in {'/download_book_cover', '/proxy_image', '/log_mobile_issue'}:
|
||||||
return jsonify({'error': message}), 400
|
return jsonify({'error': message}), 400
|
||||||
flash(message, 'error')
|
flash(message, 'error')
|
||||||
return redirect(request.referrer or url_for('home'))
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
def _get_current_module(path):
|
def _get_current_module(path):
|
||||||
"""Resolve the active UI module for navbar separation."""
|
"""Resolve the active UI module for navbar separation."""
|
||||||
@@ -657,7 +672,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'
|
||||||
@@ -715,7 +729,7 @@ def _append_audit_event_standalone(event_type, payload):
|
|||||||
db=db,
|
db=db,
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
actor=session.get('username', 'system'),
|
actor=session.get('username', 'system'),
|
||||||
payload=payload,
|
payload=str(payload),
|
||||||
request_ip=request.remote_addr,
|
request_ip=request.remote_addr,
|
||||||
source='web',
|
source='web',
|
||||||
)
|
)
|
||||||
@@ -938,7 +952,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 +1055,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 +1078,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] = {
|
||||||
@@ -1621,7 +1635,7 @@ def allowed_file(filename, file_content=None, max_size_mb=cfg.MAX_UPLOAD_MB):
|
|||||||
|
|
||||||
file_content.seek(0) # Reset file pointer after reading
|
file_content.seek(0) # Reset file pointer after reading
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Error validating image content for {filename}: {str(e)}"
|
error_msg = f"Error validating image content for {filename}"
|
||||||
app.logger.error(error_msg)
|
app.logger.error(error_msg)
|
||||||
|
|
||||||
if extension == 'png':
|
if extension == 'png':
|
||||||
@@ -1661,9 +1675,9 @@ def allowed_file(filename, file_content=None, max_size_mb=cfg.MAX_UPLOAD_MB):
|
|||||||
except Exception as raw_err:
|
except Exception as raw_err:
|
||||||
app.logger.error(f"PNG DEBUG: Error during raw file analysis: {str(raw_err)}")
|
app.logger.error(f"PNG DEBUG: Error during raw file analysis: {str(raw_err)}")
|
||||||
|
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
return False, f"Datei '{filename}' konnte nicht als Bild erkannt werden. Fehler: {str(e)}"
|
return False, f"Datei '{filename}' konnte nicht als Bild erkannt werden. "
|
||||||
|
|
||||||
# Add more content type validations as needed for other file types
|
# Add more content type validations as needed for other file types
|
||||||
|
|
||||||
@@ -2132,7 +2146,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()
|
||||||
@@ -2821,8 +2835,8 @@ def catch_all_files(filename):
|
|||||||
# If we get here, the file wasn't found
|
# If we get here, the file wasn't found
|
||||||
return Response(f"File {filename} not found", status=404)
|
return Response(f"File {filename} not found", status=404)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error in catch-all route for {filename}: {str(e)}")
|
app.logger.error(f"Error in catch-all route for {filename}: {str(e)}")
|
||||||
return Response(f"Error serving file: {str(e)}", status=500)
|
return Response(f"Error serving file {filename}", status=500)
|
||||||
|
|
||||||
"""-------------------------------------------------------------Main Views-----------------------------------------------------------------------------"""
|
"""-------------------------------------------------------------Main Views-----------------------------------------------------------------------------"""
|
||||||
|
|
||||||
@@ -3336,7 +3350,7 @@ def api_library_items():
|
|||||||
}), 200
|
}), 200
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error(f"Error fetching library items: {e}")
|
app.logger.error(f"Error fetching library items: {e}")
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': 'An error occurred while fetching library items'}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/library_scan_action', methods=['POST'])
|
@app.route('/api/library_scan_action', methods=['POST'])
|
||||||
@@ -3430,7 +3444,7 @@ def api_library_scan_action():
|
|||||||
'channel': 'library_scan',
|
'channel': 'library_scan',
|
||||||
'item_id': item_id,
|
'item_id': item_id,
|
||||||
'item_name': item_doc.get('Name', ''),
|
'item_name': item_doc.get('Name', ''),
|
||||||
'borrower': borrower_name,
|
'borrower':borrower_name,
|
||||||
'student_card_id': student_card_id,
|
'student_card_id': student_card_id,
|
||||||
'borrow_duration_days': borrow_duration_days,
|
'borrow_duration_days': borrow_duration_days,
|
||||||
}
|
}
|
||||||
@@ -3590,7 +3604,6 @@ def api_item_detail(item_id):
|
|||||||
# Basic detail HTML
|
# Basic detail HTML
|
||||||
detail_html = f"""
|
detail_html = f"""
|
||||||
<h2>{html.escape(item.get('Name', 'Untitled'))}</h2>
|
<h2>{html.escape(item.get('Name', 'Untitled'))}</h2>
|
||||||
<p><strong>Autor/Künstler:</strong> {html.escape(item.get('Autor', item.get('Author', '-')))}</p>
|
|
||||||
<p><strong>ISBN:</strong> {html.escape(item.get('ISBN', item.get('Code4', '-')))}</p>
|
<p><strong>ISBN:</strong> {html.escape(item.get('ISBN', item.get('Code4', '-')))}</p>
|
||||||
<p><strong>Beschreibung:</strong> {html.escape(item.get('Beschreibung', '-'))}</p>
|
<p><strong>Beschreibung:</strong> {html.escape(item.get('Beschreibung', '-'))}</p>
|
||||||
<p><strong>Status:</strong> {html.escape(status_label)}</p>
|
<p><strong>Status:</strong> {html.escape(status_label)}</p>
|
||||||
@@ -3599,10 +3612,9 @@ 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': 'An error occurred while fetching the item detail'}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/library_item/<item_id>/update', methods=['POST'])
|
@app.route('/api/library_item/<item_id>/update', methods=['POST'])
|
||||||
@@ -4216,7 +4228,8 @@ def student_card_barcode_download():
|
|||||||
download_name=f'schuelerausweise_all_{datetime.datetime.now().strftime("%Y%m%d_%H%M%S")}.pdf'
|
download_name=f'schuelerausweise_all_{datetime.datetime.now().strftime("%Y%m%d_%H%M%S")}.pdf'
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
flash(f'Fehler beim PDF-Download: {str(e)}', 'error')
|
app.logger.error(f"Error occurred while generating PDF for card {card['AusweisId']}: {e}")
|
||||||
|
flash('Fehler beim PDF-Download', 'error')
|
||||||
return redirect(url_for('student_cards_admin'))
|
return redirect(url_for('student_cards_admin'))
|
||||||
|
|
||||||
|
|
||||||
@@ -4388,7 +4401,8 @@ def student_card_single_barcode_download(card_id):
|
|||||||
download_name=f'ausweis_{card["AusweisId"]}.pdf'
|
download_name=f'ausweis_{card["AusweisId"]}.pdf'
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
flash(f'Fehler beim PDF-Download: {str(e)}', 'error')
|
app.logger.error(f"Error occurred while generating PDF for card {card['AusweisId']}: {e}")
|
||||||
|
flash('Fehler beim PDF-Download', 'error')
|
||||||
return redirect(url_for('student_cards_admin'))
|
return redirect(url_for('student_cards_admin'))
|
||||||
|
|
||||||
|
|
||||||
@@ -4409,19 +4423,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 +4454,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')
|
||||||
@@ -4727,7 +4739,7 @@ def get_items():
|
|||||||
'has_more': pagination_requested and ((offset + count) < total_count)
|
'has_more': pagination_requested and ((offset + count) < total_count)
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'items': [], 'error': str(e)}), 500
|
return jsonify({'items': []}), 500
|
||||||
finally:
|
finally:
|
||||||
if client:
|
if client:
|
||||||
client.close()
|
client.close()
|
||||||
@@ -4744,7 +4756,8 @@ def get_item_json(id):
|
|||||||
item['_id'] = str(item['_id'])
|
item['_id'] = str(item['_id'])
|
||||||
return jsonify(item)
|
return jsonify(item)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': str(e)}), 500
|
app.logger.error(f"Error occurred while fetching item {id}: {e}")
|
||||||
|
return jsonify({'error': 'An error occurred while fetching the item'}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/get_bookings')
|
@app.route('/get_bookings')
|
||||||
@@ -4818,7 +4831,7 @@ def get_bookings():
|
|||||||
|
|
||||||
return jsonify({'ok': True, 'bookings': result})
|
return jsonify({'ok': True, 'bookings': result})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'ok': False, 'error': str(e), 'bookings': []}), 500
|
return jsonify({'ok': False, 'bookings': []}), 500
|
||||||
finally:
|
finally:
|
||||||
if client:
|
if client:
|
||||||
client.close()
|
client.close()
|
||||||
@@ -4908,7 +4921,7 @@ def get_user_appointments():
|
|||||||
|
|
||||||
return jsonify({'ok': True, 'bookings': result})
|
return jsonify({'ok': True, 'bookings': result})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'ok': False, 'error': str(e), 'bookings': []}), 500
|
return jsonify({'ok': False, 'bookings': []}), 500
|
||||||
finally:
|
finally:
|
||||||
if client:
|
if client:
|
||||||
client.close()
|
client.close()
|
||||||
@@ -4952,7 +4965,8 @@ def api_booking_conflicts():
|
|||||||
client.close()
|
client.close()
|
||||||
return jsonify({'conflicts': result, 'count': len(result)})
|
return jsonify({'conflicts': result, 'count': len(result)})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': str(e), 'conflicts': []}), 500
|
app.logger.error(f"Error occurred while fetching booking conflicts: {e}")
|
||||||
|
return jsonify({'error': 'An error occurred while fetching booking conflicts', 'conflicts': []}), 500
|
||||||
|
|
||||||
"""Favorites management endpoints (persistent + session cache)."""
|
"""Favorites management endpoints (persistent + session cache)."""
|
||||||
def _ensure_session_favs():
|
def _ensure_session_favs():
|
||||||
@@ -5067,7 +5081,8 @@ def debug_favorites():
|
|||||||
try:
|
try:
|
||||||
db_favs = us.get_favorites(username)
|
db_favs = us.get_favorites(username)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'ok': False, 'error': f'db_error: {e}', 'session': session_favs})
|
app.logger.error(f"Error fetching DB favorites: {e}")
|
||||||
|
return jsonify({'ok': False, 'error': 'Failed to fetch DB favorites', 'session': session_favs})
|
||||||
merged = sorted(set(session_favs) | set(db_favs))
|
merged = sorted(set(session_favs) | set(db_favs))
|
||||||
return jsonify({'ok': True, 'user': username, 'session': session_favs, 'db': db_favs, 'merged': merged})
|
return jsonify({'ok': True, 'user': username, 'session': session_favs, 'db': db_favs, 'merged': merged})
|
||||||
|
|
||||||
@@ -5106,7 +5121,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
|
||||||
@@ -5128,7 +5143,8 @@ def upload_item():
|
|||||||
individual_codes_raw = sanitize_form_value(request.form.get('individual_codes', ''))
|
individual_codes_raw = sanitize_form_value(request.form.get('individual_codes', ''))
|
||||||
item_count_raw = sanitize_form_value(request.form.get('item_count', '1'))
|
item_count_raw = sanitize_form_value(request.form.get('item_count', '1'))
|
||||||
item_type_input = sanitize_form_value(request.form.get('item_type_input', ''))
|
item_type_input = sanitize_form_value(request.form.get('item_type_input', ''))
|
||||||
|
|
||||||
|
app.logger.info(f"Upload attempt by {encrypt_text(username)}: name={name!r}, ort={ort!r}, beschreibung length={len(beschreibung)}, images count={len(images)}, filters={filter_upload}, filters2={filter_upload2}, filters3={filter_upload3}, anschaffungs_jahr={anschaffungs_jahr}, anschaffungs_kosten={anschaffungs_kosten}, code_4={code_4}, isbn={isbn_raw!r}, upload_mode={upload_mode!r}, item_count={item_count_raw!r}, item_type_input={item_type_input!r}, individual_codes length={len(individual_codes_raw)}")
|
||||||
try:
|
try:
|
||||||
item_count = int(item_count_raw) if item_count_raw else 1
|
item_count = int(item_count_raw) if item_count_raw else 1
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -5175,7 +5191,7 @@ def upload_item():
|
|||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
app.logger.error(f"Error parsing mobile data: {str(e)}")
|
app.logger.error(f"Error parsing mobile data: {str(e)}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Fehler beim Verarbeiten der Formulardaten: {str(e)}"
|
error_msg = f"Fehler beim Verarbeiten der Formulardaten"
|
||||||
app.logger.error(error_msg)
|
app.logger.error(error_msg)
|
||||||
if is_mobile:
|
if is_mobile:
|
||||||
return jsonify({'success': False, 'message': error_msg}), 400
|
return jsonify({'success': False, 'message': error_msg}), 400
|
||||||
@@ -5339,7 +5355,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']]:
|
||||||
@@ -5538,7 +5554,7 @@ def upload_item():
|
|||||||
if is_png:
|
if is_png:
|
||||||
app.logger.error(f"PNG DEBUG: {image_log_prefix} Fallback PNG save also failed: {str(fallback_err)}")
|
app.logger.error(f"PNG DEBUG: {image_log_prefix} Fallback PNG save also failed: {str(fallback_err)}")
|
||||||
app.logger.error(f"PNG DEBUG: {image_log_prefix} Error type: {type(fallback_err).__name__}")
|
app.logger.error(f"PNG DEBUG: {image_log_prefix} Error type: {type(fallback_err).__name__}")
|
||||||
traceback.print_exc()
|
|
||||||
error_count += 1
|
error_count += 1
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
@@ -5646,7 +5662,7 @@ def upload_item():
|
|||||||
except Exception as webp_err:
|
except Exception as webp_err:
|
||||||
app.logger.error(f"PNG DEBUG: {image_log_prefix} Final WebP conversion failed: {str(webp_err)}")
|
app.logger.error(f"PNG DEBUG: {image_log_prefix} Final WebP conversion failed: {str(webp_err)}")
|
||||||
|
|
||||||
traceback.print_exc()
|
|
||||||
error_count += 1
|
error_count += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -5705,7 +5721,7 @@ def upload_item():
|
|||||||
if is_png:
|
if is_png:
|
||||||
app.logger.error(f"PNG DEBUG: {image_log_prefix} Could not get PNG dimensions: {str(dim_err)}")
|
app.logger.error(f"PNG DEBUG: {image_log_prefix} Could not get PNG dimensions: {str(dim_err)}")
|
||||||
app.logger.error(f"PNG DEBUG: {image_log_prefix} Error type: {type(dim_err).__name__}")
|
app.logger.error(f"PNG DEBUG: {image_log_prefix} Error type: {type(dim_err).__name__}")
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
app.logger.info(f"{image_log_prefix} Starting optimization for {saved_filename} ({original_size/1024:.1f}KB, {original_dimensions})")
|
app.logger.info(f"{image_log_prefix} Starting optimization for {saved_filename} ({original_size/1024:.1f}KB, {original_dimensions})")
|
||||||
|
|
||||||
@@ -5755,7 +5771,6 @@ def upload_item():
|
|||||||
app.logger.warning(f"{image_log_prefix} Optimization failed or returned no file")
|
app.logger.warning(f"{image_log_prefix} Optimization failed or returned no file")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error(f"{image_log_prefix} Optimization failed: {str(e)}")
|
app.logger.error(f"{image_log_prefix} Optimization failed: {str(e)}")
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
# No fallback thumbnail generation needed as we only use the main image
|
# No fallback thumbnail generation needed as we only use the main image
|
||||||
|
|
||||||
@@ -5767,7 +5782,6 @@ def upload_item():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error(f"{image_log_prefix} Unexpected error: {str(e)}")
|
app.logger.error(f"{image_log_prefix} Unexpected error: {str(e)}")
|
||||||
traceback.print_exc()
|
|
||||||
error_count += 1
|
error_count += 1
|
||||||
# Continue with the next image
|
# Continue with the next image
|
||||||
|
|
||||||
@@ -5904,7 +5918,7 @@ def upload_item():
|
|||||||
app.logger.error(f"Error generating optimized versions for {new_filename}: {e}")
|
app.logger.error(f"Error generating optimized versions for {new_filename}: {e}")
|
||||||
# If optimization fails, at least keep the original file
|
# If optimization fails, at least keep the original file
|
||||||
result = {'original': new_filename}
|
result = {'original': new_filename}
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
# If we didn't find the image, use a placeholder
|
# If we didn't find the image, use a placeholder
|
||||||
else:
|
else:
|
||||||
@@ -5940,7 +5954,7 @@ def upload_item():
|
|||||||
app.logger.info(f"Processed image {i+1}/{original_count}: {new_filename}")
|
app.logger.info(f"Processed image {i+1}/{original_count}: {new_filename}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error(f"Error processing image {i+1}/{original_count} ({dup_img}): {str(e)}")
|
app.logger.error(f"Error processing image {i+1}/{original_count} ({dup_img}): {str(e)}")
|
||||||
traceback.print_exc()
|
|
||||||
error_count += 1
|
error_count += 1
|
||||||
|
|
||||||
# Log placeholder usage
|
# Log placeholder usage
|
||||||
@@ -6093,7 +6107,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')
|
||||||
@@ -6174,7 +6188,7 @@ def duplicate_item():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error in duplicate_item: {e}")
|
print(f"Error in duplicate_item: {e}")
|
||||||
traceback.print_exc()
|
|
||||||
return jsonify({'success': False, 'message': 'Serverfehler beim Duplizieren'}), 500
|
return jsonify({'success': False, 'message': 'Serverfehler beim Duplizieren'}), 500
|
||||||
|
|
||||||
|
|
||||||
@@ -6327,7 +6341,7 @@ def delete_item(id):
|
|||||||
soft_deleted_borrows = int(delete_result.get('soft_deleted_borrows', 0))
|
soft_deleted_borrows = int(delete_result.get('soft_deleted_borrows', 0))
|
||||||
archived_files = int((delete_result.get('archive') or {}).get('archived_files', 0))
|
archived_files = int((delete_result.get('archive') or {}).get('archived_files', 0))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error(f"Error during soft-delete for item group {id}: {str(e)}")
|
app.logger.error(f"Error during soft-delete for item group {id}")
|
||||||
delete_success = False
|
delete_success = False
|
||||||
archived_files = 0
|
archived_files = 0
|
||||||
finally:
|
finally:
|
||||||
@@ -6459,7 +6473,7 @@ def bulk_delete_items():
|
|||||||
'group_item_ids': result.get('group_item_ids', []),
|
'group_item_ids': result.get('group_item_ids', []),
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error(f"Error during bulk delete: {str(e)}")
|
app.logger.error(f"Error during bulk delete for items {item_ids}: {e}")
|
||||||
return jsonify({'success': False, 'message': 'Fehler beim Sammellöschen.'}), 500
|
return jsonify({'success': False, 'message': 'Fehler beim Sammellöschen.'}), 500
|
||||||
finally:
|
finally:
|
||||||
if client:
|
if client:
|
||||||
@@ -7190,9 +7204,9 @@ def zurueckgeben(id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error in return process: {e}")
|
app.logger.error(f"Error in return process: {e}")
|
||||||
it.update_item_status(id, True)
|
it.update_item_status(id, True)
|
||||||
flash(f'Element zurückgegeben, aber ein Fehler ist aufgetreten: {str(e)}', 'warning')
|
flash('Element zurückgegeben, aber ein Fehler ist aufgetreten', 'warning')
|
||||||
else:
|
else:
|
||||||
flash('Sie sind nicht berechtigt, dieses Element zurückzugeben, oder es ist bereits verfügbar', 'error')
|
flash('Sie sind nicht berechtigt, dieses Element zurückzugeben, oder es ist bereits verfügbar', 'error')
|
||||||
|
|
||||||
@@ -7289,7 +7303,7 @@ def get_planned_bookings(item_id):
|
|||||||
client.close()
|
client.close()
|
||||||
return jsonify({'ok': True, 'bookings': bookings})
|
return jsonify({'ok': True, 'bookings': bookings})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'ok': False, 'error': str(e)}), 500
|
return jsonify({'ok': False}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/get_planned_bookings_public/<item_id>')
|
@app.route('/get_planned_bookings_public/<item_id>')
|
||||||
@@ -7315,7 +7329,7 @@ def get_planned_bookings_public(item_id):
|
|||||||
client.close()
|
client.close()
|
||||||
return jsonify({'ok': True, 'bookings': bookings})
|
return jsonify({'ok': True, 'bookings': bookings})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'ok': False, 'error': str(e)}), 500
|
return jsonify({'ok': False}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/check_availability')
|
@app.route('/check_availability')
|
||||||
@@ -7396,7 +7410,7 @@ def check_availability():
|
|||||||
client.close()
|
client.close()
|
||||||
return jsonify({'ok': True, 'available': len(conflicts) == 0, 'conflicts': conflicts})
|
return jsonify({'ok': True, 'available': len(conflicts) == 0, 'conflicts': conflicts})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'ok': False, 'error': str(e)}), 500
|
return jsonify({'ok': False}), 500
|
||||||
|
|
||||||
|
|
||||||
# def create_qr_code(id):
|
# def create_qr_code(id):
|
||||||
@@ -7494,8 +7508,9 @@ def plan_booking():
|
|||||||
if booking_type == 'single':
|
if booking_type == 'single':
|
||||||
end_date = start_date
|
end_date = start_date
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return {"success": False, "error": f"Invalid date format: {e}"}, 400
|
app.logger.error(f"Invalid date format: {e}")
|
||||||
|
return {"success": False, "error": "Invalid date format"}, 400
|
||||||
|
|
||||||
# Check if item exists
|
# Check if item exists
|
||||||
item = it.get_item(item_id)
|
item = it.get_item(item_id)
|
||||||
if not item:
|
if not item:
|
||||||
@@ -7567,16 +7582,15 @@ def plan_booking():
|
|||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
# All failed
|
# All failed
|
||||||
return {"success": False, "errors": errors}, 400
|
return {"success": False}, 500
|
||||||
else:
|
else:
|
||||||
# All succeeded
|
# All succeeded
|
||||||
return {"success": True, "booking_ids": booking_ids}
|
return {"success": True, "booking_ids": booking_ids}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
print(f"Error in plan_booking: {e}")
|
app.logger.error(f"Error in plan_booking: {e}")
|
||||||
traceback.print_exc()
|
return {"success": False, "error": f"Fehler beim Planen der Buchung"}, 500
|
||||||
return {"success": False, "error": f"Serverfehler: {str(e)}"}, 500
|
|
||||||
|
|
||||||
def process_day_bookings(item_id, booking_date, periods, notes):
|
def process_day_bookings(item_id, booking_date, periods, notes):
|
||||||
"""
|
"""
|
||||||
@@ -7671,7 +7685,7 @@ def add_booking():
|
|||||||
|
|
||||||
return jsonify({'success': True, 'booking_id': str(booking_id)})
|
return jsonify({'success': True, 'booking_id': str(booking_id)})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'error': str(e)})
|
return jsonify({'success': False})
|
||||||
|
|
||||||
@app.route('/cancel_booking/<id>', methods=['POST'])
|
@app.route('/cancel_booking/<id>', methods=['POST'])
|
||||||
def cancel_booking(id):
|
def cancel_booking(id):
|
||||||
@@ -7722,9 +7736,7 @@ def terminplan():
|
|||||||
|
|
||||||
return render_template('terminplan.html', school_periods=SCHOOL_PERIODS)
|
return render_template('terminplan.html', school_periods=SCHOOL_PERIODS)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
app.logger.error(f"Error rendering terminplan: {e}")
|
||||||
print(f"Error rendering terminplan: {e}")
|
|
||||||
traceback.print_exc()
|
|
||||||
flash('Ein Fehler ist beim Anzeigen des Kalenders aufgetreten.', 'error')
|
flash('Ein Fehler ist beim Anzeigen des Kalenders aufgetreten.', 'error')
|
||||||
return redirect(url_for('home'))
|
return redirect(url_for('home'))
|
||||||
|
|
||||||
@@ -7910,15 +7922,17 @@ def delete_user():
|
|||||||
|
|
||||||
client.close()
|
client.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
flash(f'Warnung: Ausleihungen/Reservierungen für {username} konnten nicht vollständig zurückgesetzt werden: {str(e)}', 'warning')
|
app.logger.error(f"Error resetting borrowings for user {encrypt_text(username)}: {e}")
|
||||||
|
flash(f'Warnung: Ausleihungen/Reservierungen für {username} konnten nicht vollständig zurückgesetzt werden', 'warning')
|
||||||
|
|
||||||
# Delete the user
|
# Delete the user
|
||||||
try:
|
try:
|
||||||
us.delete_user(username)
|
us.delete_user(username)
|
||||||
flash(f'Benutzer {username} erfolgreich gelöscht', 'success')
|
flash(f'Benutzer {username} erfolgreich gelöscht', 'success')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
flash(f'Fehler beim Löschen des Benutzers: {str(e)}', 'error')
|
app.logger.error(f"Error deleting user {encrypt_text(username)}: {e}")
|
||||||
|
flash('Fehler beim Löschen des Benutzers', 'error')
|
||||||
|
|
||||||
return redirect(url_for('user_del'))
|
return redirect(url_for('user_del'))
|
||||||
|
|
||||||
|
|
||||||
@@ -8016,7 +8030,7 @@ def admin_verify_audit_chain():
|
|||||||
status_code = 200 if result.get('ok') else 409
|
status_code = 200 if result.get('ok') else 409
|
||||||
return jsonify(result), status_code
|
return jsonify(result), status_code
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return jsonify({'ok': False, 'error': str(exc)}), 500
|
return jsonify({'ok': False, 'error': 'Internal server error'}), 500
|
||||||
finally:
|
finally:
|
||||||
if client:
|
if client:
|
||||||
client.close()
|
client.close()
|
||||||
@@ -8035,23 +8049,14 @@ def admin_audit_dashboard():
|
|||||||
al.ensure_audit_indexes(db)
|
al.ensure_audit_indexes(db)
|
||||||
verify_result = al.verify_audit_chain(db)
|
verify_result = al.verify_audit_chain(db)
|
||||||
|
|
||||||
audit_rows = list(
|
audit_rows = list(db['audit_log'].find({}).sort('chain_index', -1).limit(200))
|
||||||
db['audit_log'].find(
|
|
||||||
{},
|
# DEC_START: Decrypt the sensitive fields for display
|
||||||
{
|
for row in audit_rows:
|
||||||
'chain_index': 1,
|
if "payload" in row:
|
||||||
'event_type': 1,
|
# decrypt_document_fields acts in-place
|
||||||
'actor': 1,
|
decrypt_document_fields(row["payload"], SENSITIVE_AUDIT_FIELDS)
|
||||||
'source': 1,
|
# DEC_END
|
||||||
'ip': 1,
|
|
||||||
'timestamp': 1,
|
|
||||||
'created_at': 1,
|
|
||||||
'entry_hash': 1,
|
|
||||||
'prev_hash': 1,
|
|
||||||
'payload': 1,
|
|
||||||
}
|
|
||||||
).sort('chain_index', -1).limit(200)
|
|
||||||
)
|
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'admin_audit.html',
|
'admin_audit.html',
|
||||||
@@ -8096,28 +8101,18 @@ def admin_audit_export_pdf_official():
|
|||||||
])
|
])
|
||||||
)
|
)
|
||||||
|
|
||||||
audit_rows = list(
|
audit_rows = list(db['audit_log'].find({}).sort('chain_index', -1).limit(limit))
|
||||||
db['audit_log'].find(
|
|
||||||
{},
|
# DEC_START: Decrypt sensitive fields for the PDF report
|
||||||
{
|
for row in audit_rows:
|
||||||
'chain_index': 1,
|
if "payload" in row:
|
||||||
'event_type': 1,
|
decrypt_document_fields(row["payload"], SENSITIVE_AUDIT_FIELDS)
|
||||||
'actor': 1,
|
# DEC_END
|
||||||
'source': 1,
|
|
||||||
'ip': 1,
|
|
||||||
'timestamp': 1,
|
|
||||||
'created_at': 1,
|
|
||||||
'entry_hash': 1,
|
|
||||||
'prev_hash': 1,
|
|
||||||
'payload': 1,
|
|
||||||
}
|
|
||||||
).sort('chain_index', -1).limit(limit)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get school information from settings or use defaults
|
# Get school information from settings or use defaults
|
||||||
school_info = _get_school_info_for_export()
|
school_info = _get_school_info_for_export()
|
||||||
|
|
||||||
# Generate PDF
|
# Generate PDF with now-decrypted audit_rows
|
||||||
pdf_content = pdf_export.generate_audit_pdf(
|
pdf_content = pdf_export.generate_audit_pdf(
|
||||||
verify_result=verify_result,
|
verify_result=verify_result,
|
||||||
event_counts=event_counts,
|
event_counts=event_counts,
|
||||||
@@ -8125,7 +8120,7 @@ def admin_audit_export_pdf_official():
|
|||||||
export_type='official',
|
export_type='official',
|
||||||
school_info=school_info
|
school_info=school_info
|
||||||
)
|
)
|
||||||
|
|
||||||
response = make_response(pdf_content)
|
response = make_response(pdf_content)
|
||||||
response.headers['Content-Type'] = 'application/pdf'
|
response.headers['Content-Type'] = 'application/pdf'
|
||||||
response.headers['Content-Disposition'] = f'attachment; filename=audit-official-report-{datetime.datetime.utcnow().strftime("%Y%m%d-%H%M%S")}.pdf'
|
response.headers['Content-Disposition'] = f'attachment; filename=audit-official-report-{datetime.datetime.utcnow().strftime("%Y%m%d-%H%M%S")}.pdf'
|
||||||
@@ -8133,7 +8128,7 @@ def admin_audit_export_pdf_official():
|
|||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
app.logger.error(f"PDF Official Report export error: {str(exc)}\n{traceback.format_exc()}")
|
app.logger.error(f"PDF Official Report export error: {str(exc)}\n{traceback.format_exc()}")
|
||||||
return jsonify({'ok': False, 'error': str(exc)}), 500
|
return jsonify({'ok': False, 'error': 'Internal server error'}), 500
|
||||||
finally:
|
finally:
|
||||||
if client:
|
if client:
|
||||||
client.close()
|
client.close()
|
||||||
@@ -8196,7 +8191,7 @@ def admin_image_cache_stats():
|
|||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error(f"Error getting cache stats: {str(e)}")
|
app.logger.error(f"Error getting cache stats: {str(e)}")
|
||||||
return jsonify({'ok': False, 'error': str(e)}), 500
|
return jsonify({'ok': False}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/admin/image_cache_cleanup', methods=['POST'])
|
@app.route('/admin/image_cache_cleanup', methods=['POST'])
|
||||||
@@ -8245,7 +8240,7 @@ def admin_image_cache_cleanup():
|
|||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error(f"Error during image cache cleanup: {str(e)}")
|
app.logger.error(f"Error during image cache cleanup: {str(e)}")
|
||||||
return jsonify({'ok': False, 'error': str(e)}), 500
|
return jsonify({'ok': False}), 500
|
||||||
|
|
||||||
"""-----------------------------------------------------------Borrowing Management Routes-------------------------------------------------------"""
|
"""-----------------------------------------------------------Borrowing Management Routes-------------------------------------------------------"""
|
||||||
|
|
||||||
@@ -8314,7 +8309,8 @@ def admin_reset_borrowing(borrow_id):
|
|||||||
|
|
||||||
client.close()
|
client.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
flash(f'Fehler beim Zurücksetzen: {str(e)}', 'error')
|
app.logger.error(f"Error resetting borrowing status for {borrow_id}: {e}")
|
||||||
|
flash('Fehler beim Zurücksetzen', 'error')
|
||||||
|
|
||||||
return redirect(url_for('admin_borrowings'))
|
return redirect(url_for('admin_borrowings'))
|
||||||
|
|
||||||
@@ -8928,10 +8924,11 @@ def admin_reset_user_password():
|
|||||||
# Reset the password
|
# Reset the password
|
||||||
try:
|
try:
|
||||||
us.update_password(username, new_password)
|
us.update_password(username, new_password)
|
||||||
flash(f'Passwort für {username} wurde erfolgreich zurückgesetzt auf: {new_password}', 'success')
|
flash(f'Passwort für {encrypt_text(username)} wurde erfolgreich zurückgesetzt auf: {new_password}', 'success')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
flash(f'Fehler beim Zurücksetzen des Passworts: {str(e)}', 'error')
|
app.logger.error(f'Error resetting password for {encrypt_text(username)}: {e}')
|
||||||
|
flash('Fehler beim Zurücksetzen des Passworts', 'error')
|
||||||
|
|
||||||
return redirect(url_for('user_del'))
|
return redirect(url_for('user_del'))
|
||||||
|
|
||||||
|
|
||||||
@@ -9431,7 +9428,8 @@ def search_word(word):
|
|||||||
|
|
||||||
return jsonify({"success": True, "response": list(id_set)})
|
return jsonify({"success": True, "response": list(id_set)})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"success": False, "response": str(e)})
|
app.logger.error(f"Error searching for word: {e}")
|
||||||
|
return jsonify({"success": False})
|
||||||
|
|
||||||
def _fetch_from_google_books(clean_isbn):
|
def _fetch_from_google_books(clean_isbn):
|
||||||
"""Source 1: Google Books API (Free, No Key required for basic use)"""
|
"""Source 1: Google Books API (Free, No Key required for basic use)"""
|
||||||
@@ -9730,16 +9728,13 @@ def fetch_book_info(isbn):
|
|||||||
return jsonify({"error": f"Kein Buch zu dieser ISBN gefunden: {clean_isbn}"}), 404
|
return jsonify({"error": f"Kein Buch zu dieser ISBN gefunden: {clean_isbn}"}), 404
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error fetching book data: {e}")
|
app.logger.error(f"Error fetching book data: {e}")
|
||||||
return jsonify({"error": f"Failed to fetch book information: {str(e)}"}), 500
|
return jsonify({"error": f"Failed to fetch book information"}), 500
|
||||||
|
|
||||||
@app.route('/download_book_cover', methods=['POST'])
|
@app.route('/download_book_cover', methods=['POST'])
|
||||||
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 +9754,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 +9773,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 +9782,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 +9793,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,10 +9812,13 @@ def download_book_cover():
|
|||||||
"message": "Image downloaded successfully"
|
"message": "Image downloaded successfully"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
app.logger.error(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}")
|
app.logger.error(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"}), 500
|
||||||
"""
|
"""
|
||||||
@app.route('/proxy_image')
|
@app.route('/proxy_image')
|
||||||
def proxy_image():
|
def proxy_image():
|
||||||
@@ -9881,8 +9877,8 @@ def proxy_image():
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error in proxy_image: {e}")
|
app.logger.error(f"Error in proxy_image: {e}")
|
||||||
return jsonify({"error": f"Error fetching image: {str(e)}"}), 500
|
return jsonify({"error": f"Error fetching image"}), 500
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -9978,11 +9974,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 +10179,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 +10258,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:
|
||||||
@@ -10735,8 +10726,8 @@ def schedule_appointment():
|
|||||||
if has_conflict:
|
if has_conflict:
|
||||||
return jsonify({'success': False, 'message': 'Termin kollidiert mit bestehender Buchung'}), 409
|
return jsonify({'success': False, 'message': 'Termin kollidiert mit bestehender Buchung'}), 409
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error checking for booking conflicts: {e}")
|
app.logger.error(f"Error checking for booking conflicts: {e}")
|
||||||
return jsonify({'success': False, 'message': f'Fehler beim Prüfen der Verfügbarkeit: {str(e)}'}), 500
|
return jsonify({'success': False, 'message': f'Fehler beim Prüfen der Verfügbarkeit'}), 500
|
||||||
|
|
||||||
# Check if the appointment should already be active
|
# Check if the appointment should already be active
|
||||||
now = datetime.datetime.now()
|
now = datetime.datetime.now()
|
||||||
@@ -10807,8 +10798,8 @@ def schedule_appointment():
|
|||||||
if not appointment_id:
|
if not appointment_id:
|
||||||
return jsonify({'success': False, 'message': 'Termin konnte nicht erstellt werden'}), 500
|
return jsonify({'success': False, 'message': 'Termin konnte nicht erstellt werden'}), 500
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error creating booking: {e}")
|
app.logger.error(f"Error creating booking: {e}")
|
||||||
return jsonify({'success': False, 'message': f'Fehler beim Erstellen des Termins: {str(e)}'}), 500
|
return jsonify({'success': False, 'message': f'Fehler beim Erstellen des Termins'}), 500
|
||||||
|
|
||||||
# If we got this far, we have a valid appointment_id
|
# If we got this far, we have a valid appointment_id
|
||||||
try:
|
try:
|
||||||
@@ -10842,14 +10833,14 @@ def schedule_appointment():
|
|||||||
return jsonify({'success': False, 'message': 'Element konnte nicht mit Termininformationen aktualisiert werden'}), 500
|
return jsonify({'success': False, 'message': 'Element konnte nicht mit Termininformationen aktualisiert werden'}), 500
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error updating item with appointment info: {e}")
|
app.logger.error(f"Error updating item with appointment info: {e}")
|
||||||
return jsonify({'success': False, 'message': f'Fehler beim Aktualisieren des Elements: {str(e)}'}), 500
|
return jsonify({'success': False, 'message': f'Fehler beim Aktualisieren des Elements'}), 500
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error creating appointment: {e}")
|
app.logger.error(f"Error creating appointment: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
|
||||||
return jsonify({'success': False, 'message': f'Serverfehler aufgetreten: {str(e)}'}), 500
|
return jsonify({'success': False, 'message': f'Serverfehler aufgetreten'}), 500
|
||||||
|
|
||||||
@app.route('/cancel_ausleihung/<id>', methods=['POST'])
|
@app.route('/cancel_ausleihung/<id>', methods=['POST'])
|
||||||
def cancel_ausleihung_route(id):
|
def cancel_ausleihung_route(id):
|
||||||
@@ -10923,16 +10914,15 @@ def cancel_ausleihung_route(id):
|
|||||||
next_appt = item_doc.get('NextAppointment', {})
|
next_appt = item_doc.get('NextAppointment', {})
|
||||||
if next_appt and str(next_appt.get('appointment_id')) == str(id):
|
if next_appt and str(next_appt.get('appointment_id')) == str(id):
|
||||||
cleared = it.clear_item_next_appointment(item_id)
|
cleared = it.clear_item_next_appointment(item_id)
|
||||||
print(f"Cleared NextAppointment for item {item_id}: {cleared}")
|
|
||||||
except Exception as clear_err:
|
except Exception as clear_err:
|
||||||
print(f"Warning: could not clear NextAppointment for cancelled ausleihung {id}: {clear_err}")
|
app.logger.warning(f"Warning: could not clear NextAppointment for cancelled ausleihung {id}: {clear_err}")
|
||||||
else:
|
else:
|
||||||
print(f"Failed to cancel ausleihung with ID: {id}")
|
app.logger.warning(f"Failed to cancel ausleihung with ID: {id}")
|
||||||
flash('Fehler beim Stornieren der Ausleihung', 'error')
|
flash('Fehler beim Stornieren der Ausleihung', 'error')
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error canceling ausleihung: {e}")
|
app.logger.warning(f"Error canceling ausleihung: {e}")
|
||||||
flash(f'Fehler: {str(e)}', 'error')
|
flash(f'Fehler', 'error')
|
||||||
|
|
||||||
return redirect(url_for('my_borrowed_items'))
|
return redirect(url_for('my_borrowed_items'))
|
||||||
|
|
||||||
@@ -10963,22 +10953,21 @@ def reset_item(id):
|
|||||||
if result['success']:
|
if result['success']:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'message': result['message'],
|
'message': 'Item reset successfully'
|
||||||
'details': result.get('details', {})
|
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': False,
|
'success': False,
|
||||||
'message': result['message']
|
'message': 'Failed to reset item'
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error in reset_item route: {e}")
|
print(f"Error in reset_item route: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': f'Serverfehler: {str(e)}'
|
'error': f'Serverfehler'
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
# New image and video optimization functions
|
# New image and video optimization functions
|
||||||
@@ -11124,7 +11113,6 @@ def create_image_thumbnail(image_path, thumbnail_path, size, debug_prefix=""):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error creating image thumbnail for {image_path}: {str(e)}")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -11165,11 +11153,11 @@ def create_video_thumbnail(video_path, thumbnail_path, size):
|
|||||||
|
|
||||||
return success
|
return success
|
||||||
else:
|
else:
|
||||||
print(f"ffmpeg failed for {video_path}: {result.stderr}")
|
app.logger.error(f"ffmpeg failed for {video_path}: {result.stderr}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error creating video thumbnail for {video_path}: {str(e)}")
|
app.logger.error(f"Error creating video thumbnail for {video_path}: {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -11396,7 +11384,7 @@ def generate_optimized_versions(filename, max_original_width=500, target_size_kb
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error(f"{log_prefix} Failed to process image: {str(e)}")
|
app.logger.error(f"{log_prefix} Failed to process image: {str(e)}")
|
||||||
traceback.print_exc()
|
|
||||||
# Just copy the original file as is
|
# Just copy the original file as is
|
||||||
if not is_webp_ext and os.path.exists(original_path) and not os.path.exists(converted_path):
|
if not is_webp_ext and os.path.exists(original_path) and not os.path.exists(converted_path):
|
||||||
try:
|
try:
|
||||||
@@ -11425,7 +11413,7 @@ def generate_optimized_versions(filename, max_original_width=500, target_size_kb
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error(f"{log_prefix} Unhandled exception in optimization: {str(e)}")
|
app.logger.error(f"{log_prefix} Unhandled exception in optimization: {str(e)}")
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
# If anything went wrong but the original file exists, just use it
|
# If anything went wrong but the original file exists, just use it
|
||||||
if os.path.exists(original_path):
|
if os.path.exists(original_path):
|
||||||
@@ -11563,10 +11551,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>')
|
||||||
@@ -11661,7 +11649,7 @@ def cleanup_old_optimized_images(max_age_days=30):
|
|||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error(f"Error during optimized image cleanup: {str(e)}")
|
app.logger.error(f"Error during optimized image cleanup: {str(e)}")
|
||||||
return {'deleted': 0, 'freed_mb': 0, 'error': str(e)}
|
return {'deleted': 0, 'freed_mb': 0}
|
||||||
|
|
||||||
|
|
||||||
@app.route('/log_mobile_issue', methods=['POST'])
|
@app.route('/log_mobile_issue', methods=['POST'])
|
||||||
@@ -11706,7 +11694,7 @@ def log_mobile_issue():
|
|||||||
return jsonify({'success': True})
|
return jsonify({'success': True})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error(f"Error logging mobile issue: {str(e)}")
|
app.logger.error(f"Error logging mobile issue: {str(e)}")
|
||||||
return jsonify({'success': False, 'error': str(e)})
|
return jsonify({'success': False})
|
||||||
|
|
||||||
def delete_item_images(filenames):
|
def delete_item_images(filenames):
|
||||||
"""
|
"""
|
||||||
@@ -11848,7 +11836,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'
|
||||||
@@ -11858,7 +11846,7 @@ def subscribe_to_push():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error(f'Error subscribing to push: {e}')
|
app.logger.error(f'Error subscribing to push: {e}')
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/push/unsubscribe', methods=['POST'])
|
@app.route('/api/push/unsubscribe', methods=['POST'])
|
||||||
@@ -11885,7 +11873,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'
|
||||||
@@ -11895,7 +11883,7 @@ def unsubscribe_from_push():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error(f'Error unsubscribing from push: {e}')
|
app.logger.error(f'Error unsubscribing from push: {e}')
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/push/subscriptions', methods=['GET'])
|
@app.route('/api/push/subscriptions', methods=['GET'])
|
||||||
@@ -11929,7 +11917,7 @@ def get_push_subscriptions():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error(f'Error getting push subscriptions: {e}')
|
app.logger.error(f'Error getting push subscriptions: {e}')
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/push/vapid-key', methods=['GET'])
|
@app.route('/api/push/vapid-key', methods=['GET'])
|
||||||
@@ -11954,7 +11942,7 @@ def get_vapid_key():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error(f'Error getting VAPID key: {e}')
|
app.logger.error(f'Error getting VAPID key: {e}')
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/push/test', methods=['POST'])
|
@app.route('/api/push/test', methods=['POST'])
|
||||||
@@ -11993,4 +11981,4 @@ def test_push_notification():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error(f'Error sending test push: {e}')
|
app.logger.error(f'Error sending test push: {e}')
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False}), 500
|
||||||
|
|||||||
@@ -1235,5 +1235,5 @@ def reset_item_completely(item_id):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return {
|
||||||
'success': False,
|
'success': False,
|
||||||
'message': f'Fehler beim Zurücksetzen: {str(e)}'
|
'message': f'Fehler beim Zurücksetzen.'
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,7 @@ import Web.modules.database.settings as cfg
|
|||||||
from Web.modules.database.settings import MongoClient
|
from Web.modules.database.settings import MongoClient
|
||||||
|
|
||||||
|
|
||||||
LIBRARY_ITEM_TYPES = ('book', 'cd', 'dvd', 'media')
|
LIBRARY_ITEM_TYPES = ('book', 'cd', 'dvd', 'media', 'schulbuch')
|
||||||
|
|
||||||
|
|
||||||
def _non_library_query(extra_query=None):
|
def _non_library_query(extra_query=None):
|
||||||
|
|||||||
+156
-78
@@ -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
|
||||||
|
|
||||||
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 = ()):
|
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):
|
||||||
|
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,61 +91,53 @@ 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),
|
||||||
'slots_booked': [], # -> [(start_time, name,(custom1, custom2,...)), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable
|
'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
|
||||||
'Created': datetime.datetime.now(),
|
'Created': datetime.datetime.now(),
|
||||||
'LastUpdated': datetime.datetime.now()
|
'LastUpdated': datetime.datetime.now()
|
||||||
}
|
}
|
||||||
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,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)
|
||||||
@@ -167,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."""
|
"""
|
||||||
remove_done()
|
Return upcoming appointment plans for a user, handling encrypted database records.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if hasattr(globals(), '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({
|
||||||
{
|
'date_end': {'$gte': today},
|
||||||
'user': str(user or '').strip(),
|
})
|
||||||
'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()
|
||||||
|
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
|
from Web.modules.inventarsystem.data_protection import encrypt_document_fields, decrypt_document_fields
|
||||||
|
|
||||||
from pymongo.errors import DuplicateKeyError
|
from pymongo.errors import DuplicateKeyError
|
||||||
|
|
||||||
@@ -24,21 +25,34 @@ def _entry_hash(prev_hash, payload):
|
|||||||
base = f"{prev_hash}|{_stable_json(payload)}"
|
base = f"{prev_hash}|{_stable_json(payload)}"
|
||||||
return hashlib.sha256(base.encode("utf-8")).hexdigest()
|
return hashlib.sha256(base.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
def get_decrypted_audit_logs(db, query=None, decrypt_fields=None):
|
||||||
def append_audit_event(db, event_type, actor, payload, request_ip=None, source="web", max_retries=5):
|
|
||||||
"""
|
"""
|
||||||
Append an audit event to a tamper-evident chain.
|
Retrieve and decrypt audit logs for analysis.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: MongoDB database handle.
|
query (dict): MongoDB query filter.
|
||||||
event_type (str): Event category.
|
decrypt_fields (list): Fields within the 'payload' that should be decrypted.
|
||||||
actor (str): User/system who performed the action.
|
"""
|
||||||
payload (dict): Event details.
|
logs = db["audit_log"]
|
||||||
request_ip (str, optional): Request origin.
|
cursor = logs.find(query or {}).sort("chain_index", 1)
|
||||||
source (str): Source subsystem.
|
|
||||||
|
results = []
|
||||||
|
for entry in cursor:
|
||||||
|
# Decrypt specific fields if provided
|
||||||
|
if decrypt_fields:
|
||||||
|
decrypt_document_fields(entry.get("payload", {}), decrypt_fields)
|
||||||
|
results.append(entry)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Inserted audit entry.
|
def append_audit_event(db, event_type, actor, payload, request_ip=None, source="web", max_retries=5, encrypt_fields=None):
|
||||||
|
"""
|
||||||
|
Append an audit event, optionally encrypting specific payload fields.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
...
|
||||||
|
encrypt_fields (list, optional): List of keys in 'payload' to encrypt.
|
||||||
"""
|
"""
|
||||||
logs = db["audit_log"]
|
logs = db["audit_log"]
|
||||||
attempts = 0
|
attempts = 0
|
||||||
@@ -49,15 +63,24 @@ def append_audit_event(db, event_type, actor, payload, request_ip=None, source="
|
|||||||
chain_index = int(previous.get("chain_index", 0)) + 1 if previous else 1
|
chain_index = int(previous.get("chain_index", 0)) + 1 if previous else 1
|
||||||
|
|
||||||
timestamp = datetime.datetime.utcnow()
|
timestamp = datetime.datetime.utcnow()
|
||||||
|
|
||||||
|
# 1. Create the payload dictionary
|
||||||
|
event_payload = payload or {}
|
||||||
|
|
||||||
|
# 2. Encrypt sensitive fields in-place if requested
|
||||||
|
if encrypt_fields:
|
||||||
|
encrypt_document_fields(event_payload, encrypt_fields)
|
||||||
|
|
||||||
entry_payload = {
|
entry_payload = {
|
||||||
"event_type": event_type,
|
"event_type": event_type,
|
||||||
"actor": actor or "system",
|
"actor": actor or "system",
|
||||||
"source": source,
|
"source": source,
|
||||||
"ip": request_ip or "",
|
"ip": request_ip or "",
|
||||||
"payload": payload or {},
|
"payload": event_payload,
|
||||||
"timestamp": timestamp.isoformat() + "Z",
|
"timestamp": timestamp.isoformat() + "Z",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 3. Hash the payload (which now contains encrypted values)
|
||||||
entry_hash = _entry_hash(prev_hash, entry_payload)
|
entry_hash = _entry_hash(prev_hash, entry_payload)
|
||||||
|
|
||||||
entry = {
|
entry = {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 = ()) -> 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)
|
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()
|
||||||
|
|
||||||
@@ -259,7 +262,7 @@ def new(date_start: str, date_end: str, time_span: list, slots, slot_length, use
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def book_slot(id, date_start_time, name, custom:list=()):
|
def book_slot(id, date_start_time, name, custom: tuple = ()):
|
||||||
try:
|
try:
|
||||||
item = termin.get_item(id)
|
item = termin.get_item(id)
|
||||||
if not item:
|
if not item:
|
||||||
@@ -269,18 +272,32 @@ def book_slot(id, date_start_time, name, custom:list=()):
|
|||||||
if not isinstance(slots, list):
|
if not isinstance(slots, list):
|
||||||
slots = []
|
slots = []
|
||||||
|
|
||||||
|
# 1. Check overall total booking capacity
|
||||||
capacity = int(item.get('slots', 0) or 0)
|
capacity = int(item.get('slots', 0) or 0)
|
||||||
if capacity and len(slots) >= capacity:
|
if capacity and len(slots) >= capacity:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# 2. Prevent the exact same user from double-booking the exact same slot
|
||||||
for existing in slots:
|
for existing in slots:
|
||||||
if isinstance(existing, (list, tuple)) and len(existing) >= 2:
|
if isinstance(existing, (list, tuple)) and len(existing) >= 2:
|
||||||
if existing[0] == date_start_time and existing[1] == name:
|
if existing[0] == date_start_time and existing[1] == name:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# 3. Check concurrent capacity for this specific time slot
|
||||||
|
clients_per_slot = int(item.get('clients_per_slot', 1) or 1)
|
||||||
|
bookings_at_time = sum(
|
||||||
|
1 for existing in slots
|
||||||
|
if isinstance(existing, (list, tuple)) and len(existing) > 0 and existing[0] == date_start_time
|
||||||
|
)
|
||||||
|
|
||||||
|
if bookings_at_time >= clients_per_slot:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Append the new booking successfully
|
||||||
slots.append((date_start_time, name, custom))
|
slots.append((date_start_time, name, custom))
|
||||||
success = termin.update(id, slots)
|
success = termin.update(id, slots)
|
||||||
return bool(success)
|
return bool(success)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error booking slot: {e}")
|
print(f"Error booking slot: {e}")
|
||||||
return False
|
return False
|
||||||
@@ -331,32 +348,46 @@ def get_available(id):
|
|||||||
date_end = termin_range.get('date_end')
|
date_end = termin_range.get('date_end')
|
||||||
time_span = termin_range.get('time_span', [])
|
time_span = termin_range.get('time_span', [])
|
||||||
|
|
||||||
|
# Safe integer parsing without heavy try-except blocks
|
||||||
try:
|
try:
|
||||||
total_slots = int(termin_range.get('slots', 0) or 0)
|
total_slots = int(termin_range.get('slots', 0) or 0)
|
||||||
except Exception:
|
except (ValueError, TypeError):
|
||||||
total_slots = 0
|
total_slots = 0
|
||||||
|
|
||||||
|
# Support both spellings gracefully
|
||||||
|
raw_length = termin_range.get('slot_length') or termin_range.get('slot_lenght') or 0
|
||||||
try:
|
try:
|
||||||
slot_length = int(termin_range.get('slot_length') or termin_range.get('slot_lenght') or 0)
|
slot_length = int(raw_length)
|
||||||
except Exception:
|
except (ValueError, TypeError):
|
||||||
slot_length = termin_range.get('slot_length') or termin_range.get('slot_lenght')
|
slot_length = raw_length
|
||||||
|
|
||||||
|
clients_per_slot = int(termin_range.get('clients_per_slot', 1) or 1)
|
||||||
booked = termin_range.get('slots_booked', []) or []
|
booked = termin_range.get('slots_booked', []) or []
|
||||||
|
|
||||||
|
# Normalize the bookings list safely
|
||||||
normalized = []
|
normalized = []
|
||||||
|
bookings_by_time = {} # Tracks how many people are in each specific time slot
|
||||||
|
|
||||||
for s in booked:
|
for s in booked:
|
||||||
if isinstance(s, (list, tuple)) and len(s) >= 2:
|
if isinstance(s, (list, tuple)) and len(s) >= 2:
|
||||||
normalized.append({'start': s[0], 'name': s[1]})
|
slot_time = s[0]
|
||||||
|
item = {'start': slot_time, 'name': s[1]}
|
||||||
elif isinstance(s, dict):
|
elif isinstance(s, dict):
|
||||||
normalized.append(s)
|
slot_time = s.get('start')
|
||||||
|
item = s
|
||||||
else:
|
else:
|
||||||
normalized.append({'value': s})
|
slot_time = str(s)
|
||||||
|
item = {'value': s}
|
||||||
|
|
||||||
|
normalized.append(item)
|
||||||
|
|
||||||
|
# Count concurrent bookings per timestamp
|
||||||
|
if slot_time:
|
||||||
|
bookings_by_time[slot_time] = bookings_by_time.get(slot_time, 0) + 1
|
||||||
|
|
||||||
|
# Calculate remaining total capacity safely
|
||||||
slots_used = len(normalized)
|
slots_used = len(normalized)
|
||||||
try:
|
slots_left = max(0, total_slots - slots_used)
|
||||||
slots_left = max(0, int(total_slots) - slots_used)
|
|
||||||
except Exception:
|
|
||||||
slots_left = max(0, slots_used - slots_used)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'date_start': date_start,
|
'date_start': date_start,
|
||||||
@@ -365,8 +396,10 @@ def get_available(id):
|
|||||||
'slot_length': slot_length,
|
'slot_length': slot_length,
|
||||||
'slot_lenght': slot_length,
|
'slot_lenght': slot_length,
|
||||||
'slots_total': total_slots,
|
'slots_total': total_slots,
|
||||||
|
'clients_per_slot': clients_per_slot,
|
||||||
'slots_booked': normalized,
|
'slots_booked': normalized,
|
||||||
'slots_left': slots_left,
|
'slots_left': slots_left,
|
||||||
|
'bookings_by_time': bookings_by_time
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error getting available slots: {e}")
|
print(f"Error getting available slots: {e}")
|
||||||
@@ -375,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,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')
|
||||||
@@ -88,8 +128,7 @@ def export_csv(appointment_id):
|
|||||||
response_content = si.getvalue()
|
response_content = si.getvalue()
|
||||||
output = make_response(response_content)
|
output = make_response(response_content)
|
||||||
|
|
||||||
# Der Byte Order Mark (BOM) zwingt Excel dazu, UTF-8 (Umlaute) direkt richtig zu erkennen
|
output.data = b'\xef\xbb\xbf' + output.data
|
||||||
output.data = b'\xef\xbb\xbf' + output.data.encode('utf-8')
|
|
||||||
|
|
||||||
# Dateiname generieren
|
# Dateiname generieren
|
||||||
title_slug = appointment_item.get('title', 'export').replace(' ', '_')
|
title_slug = appointment_item.get('title', 'export').replace(' ', '_')
|
||||||
@@ -115,9 +154,9 @@ def client(appointment_id):
|
|||||||
appointment_item = termin.get_item(appointment_id) or {}
|
appointment_item = termin.get_item(appointment_id) or {}
|
||||||
appointment_owner = str(appointment_item.get('user', '') or '').strip()
|
appointment_owner = str(appointment_item.get('user', '') or '').strip()
|
||||||
|
|
||||||
# Extract the custom fields defined by the appointment owner
|
|
||||||
custom_fields = appointment_item.get('custom_fields', [])
|
custom_fields = appointment_item.get('custom_fields', [])
|
||||||
|
|
||||||
|
# Permissions check
|
||||||
can_view_booking_names = False
|
can_view_booking_names = False
|
||||||
if current_user:
|
if current_user:
|
||||||
try:
|
try:
|
||||||
@@ -125,6 +164,7 @@ def client(appointment_id):
|
|||||||
except Exception:
|
except Exception:
|
||||||
can_view_booking_names = bool(current_user == appointment_owner)
|
can_view_booking_names = bool(current_user == appointment_owner)
|
||||||
|
|
||||||
|
# Sanitize data for public/client view
|
||||||
available_for_view = dict(available)
|
available_for_view = dict(available)
|
||||||
if not can_view_booking_names:
|
if not can_view_booking_names:
|
||||||
sanitized_bookings = []
|
sanitized_bookings = []
|
||||||
@@ -140,21 +180,19 @@ def client(appointment_id):
|
|||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
action = request.form.get('action', 'book')
|
action = request.form.get('action', 'book')
|
||||||
|
|
||||||
# Fall 1: ADMIN/AUTOR storniert einen Termin
|
# Case 1: Admin/Owner cancels a booking
|
||||||
if action == 'delete' and can_view_booking_names:
|
if action == 'delete' and can_view_booking_names:
|
||||||
slot_time = request.form.get('slot_time')
|
slot_time = request.form.get('slot_time')
|
||||||
client_name = request.form.get('target_client_name')
|
client_name = request.form.get('target_client_name')
|
||||||
|
|
||||||
# Aktuelle Buchungen direkt aus dem DB-Item holen
|
|
||||||
current_slots = appointment_item.get('slots_booked', []) or []
|
current_slots = appointment_item.get('slots_booked', []) or []
|
||||||
|
|
||||||
# Filtere den zu löschenden Slot heraus (Prüfung auf Zeit und Name)
|
# Keep everything EXCEPT the item targeted for deletion
|
||||||
updated_slots = [
|
updated_slots = [
|
||||||
slot for slot in current_slots
|
slot for slot in current_slots
|
||||||
if not (isinstance(slot, (list, tuple)) and slot[0] == slot_time and slot[1] == client_name)
|
if not (isinstance(slot, (list, tuple)) and slot[0] == slot_time and slot[1] == client_name)
|
||||||
]
|
]
|
||||||
|
|
||||||
# In DB schreiben via deiner existierenden termin.update() Funktion
|
|
||||||
if termin.update(appointment_id, updated_slots):
|
if termin.update(appointment_id, updated_slots):
|
||||||
flash('Buchung wurde erfolgreich gelöscht.', 'success')
|
flash('Buchung wurde erfolgreich gelöscht.', 'success')
|
||||||
else:
|
else:
|
||||||
@@ -162,11 +200,11 @@ def client(appointment_id):
|
|||||||
|
|
||||||
return redirect(url_for('terminplaner.client', appointment_id=appointment_id, tenant=_current_tenant_id() or None))
|
return redirect(url_for('terminplaner.client', appointment_id=appointment_id, tenant=_current_tenant_id() or None))
|
||||||
|
|
||||||
# Fall 2: NORMALER CLIENT bucht einen Termin
|
# Case 2: Client books a slot
|
||||||
elif action == 'book':
|
elif action == 'book':
|
||||||
start_daytime = request.form.get('start_day_time')
|
start_daytime = request.form.get('start_day_time')
|
||||||
username = request.form.get('client_name')
|
username = request.form.get('client_name')
|
||||||
custom_answers = request.form.getlist('custom_answers')
|
custom_answers = tuple(request.form.getlist('custom_answers')) # Cast to tuple for DB safety
|
||||||
|
|
||||||
if not start_daytime or not username:
|
if not start_daytime or not username:
|
||||||
flash('Bitte Name und gewünschte Uhrzeit angeben.', 'error')
|
flash('Bitte Name und gewünschte Uhrzeit angeben.', 'error')
|
||||||
@@ -177,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):
|
||||||
@@ -191,7 +230,7 @@ def client(appointment_id):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
flash('Der Termin konnte nicht gespeichert werden.', 'error')
|
flash('Der Termin konnte nicht gespeichert werden. Eventuell ist der Slot bereits voll.', 'error')
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'termin_client.html',
|
'termin_client.html',
|
||||||
@@ -201,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')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -253,7 +293,7 @@ def delete_appointment(appointment_id):
|
|||||||
@appoint_bp.route('/configure', methods=['GET', 'POST'])
|
@appoint_bp.route('/configure', methods=['GET', 'POST'])
|
||||||
def configure():
|
def configure():
|
||||||
"""
|
"""
|
||||||
Route for authenticated persons to configure a new appointment for them
|
Route for authenticated persons to configure a new appointment schedule
|
||||||
"""
|
"""
|
||||||
guard = _require_module_enabled()
|
guard = _require_module_enabled()
|
||||||
if guard:
|
if guard:
|
||||||
@@ -274,6 +314,11 @@ def configure():
|
|||||||
add_to_calendar = request.form.get('add_to_calendar') == 'on'
|
add_to_calendar = request.form.get('add_to_calendar') == 'on'
|
||||||
title = request.form.get('title', '').strip()
|
title = request.form.get('title', '').strip()
|
||||||
custom = request.form.getlist('custom_fields')
|
custom = request.form.getlist('custom_fields')
|
||||||
|
|
||||||
|
try:
|
||||||
|
clients_p_slot = int(request.form.get('clients_per_slot', 1))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
clients_p_slot = 1
|
||||||
|
|
||||||
if not start or not end or not time or not slots_amount or not slot_length or not title:
|
if not start or not end or not time or not slots_amount or not slot_length or not title:
|
||||||
flash('Bitte alle Pflichtfelder ausfüllen.', 'error')
|
flash('Bitte alle Pflichtfelder ausfüllen.', 'error')
|
||||||
@@ -284,28 +329,48 @@ def configure():
|
|||||||
email_service_enabled=cfg.EMAIL_ENABLED,
|
email_service_enabled=cfg.EMAIL_ENABLED,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Variablen im Funktionsaufruf aktualisiert
|
# Call the database service function (standardized to match your underlying code)
|
||||||
result = appointment_service.new(start, end, time, slots_amount, slot_length, session["username"], mail, note, calendar_enabled=add_to_calendar, title=title, custom_fields=custom)
|
inserted_id = appointment_service.new(
|
||||||
|
date_start=start,
|
||||||
|
date_end=end,
|
||||||
|
time_span=time,
|
||||||
|
slots=slots_amount,
|
||||||
|
slot_length=slot_length,
|
||||||
|
user=session["username"],
|
||||||
|
mail=mail,
|
||||||
|
note=note,
|
||||||
|
calendar_enabled=add_to_calendar,
|
||||||
|
title=title,
|
||||||
|
custom_fields=custom,
|
||||||
|
clients_per_slot=clients_p_slot
|
||||||
|
)
|
||||||
|
|
||||||
|
if not inserted_id:
|
||||||
|
flash('Fehler beim Erstellen des Terminplans.', 'error')
|
||||||
|
return redirect(url_for('terminplaner.configure'))
|
||||||
|
|
||||||
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=result['link'],
|
generated_link=inserted_id['link'],
|
||||||
calendar_link=result.get('calendar_link'),
|
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')
|
||||||
)
|
)
|
||||||
elif request.method == "GET":
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'termin_configure.html',
|
'termin_configure.html',
|
||||||
school_periods=cfg.SCHOOL_PERIODS,
|
school_periods=cfg.SCHOOL_PERIODS,
|
||||||
generated_link=None,
|
generated_link=None,
|
||||||
calendar_link=None,
|
calendar_link=None,
|
||||||
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')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@appoint_bp.route('/calendar/<appointment_id>.ics', methods=['GET'])
|
@appoint_bp.route('/calendar/<appointment_id>.ics', methods=['GET'])
|
||||||
@@ -343,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()
|
||||||
@@ -353,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')
|
||||||
)
|
)
|
||||||
+71
-110
@@ -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:
|
||||||
|
logger.error(f"Failed to decrypt subscription payload for hash {user_hash}: {e}")
|
||||||
|
|
||||||
|
return decrypted_subs
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Error getting push subscriptions for {username}: {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,22 +393,19 @@ 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')
|
||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Error ensuring push subscriptions collection: {e}')
|
logger.error(f'Error ensuring push subscriptions collection: {e}')
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -1057,7 +1057,7 @@
|
|||||||
document.getElementById('detailModal').style.display = 'none';
|
document.getElementById('detailModal').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// 5. EVENT LISTENERS INITIALIZATION & ON-LOAD INITIALIZER
|
// 5. EVENT LISTENERS INITIALIZATION & ON-LOAD INITIALIZER
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
function wireScannerUi() {
|
function wireScannerUi() {
|
||||||
|
|||||||
@@ -322,6 +322,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
(function () {
|
(function () {
|
||||||
const available = {{ available|tojson }};
|
const available = {{ available|tojson }};
|
||||||
const appointmentId = {{ appointment_id|tojson }};
|
const appointmentId = {{ appointment_id|tojson }};
|
||||||
|
const canViewBookingNames = {{ can_view_booking_names|tojson }}; // Injected from Flask
|
||||||
const slider = document.getElementById('day-slider');
|
const slider = document.getElementById('day-slider');
|
||||||
const sliderLabel = document.getElementById('day-slider-label');
|
const sliderLabel = document.getElementById('day-slider-label');
|
||||||
const sliderWrap = slider ? slider.closest('.day-slider-wrap') : null;
|
const sliderWrap = slider ? slider.closest('.day-slider-wrap') : null;
|
||||||
@@ -335,10 +336,10 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
const confirmBookingWithIcsBtn = document.getElementById('confirm-booking-with-ics');
|
const confirmBookingWithIcsBtn = document.getElementById('confirm-booking-with-ics');
|
||||||
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
|
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
|
||||||
|
|
||||||
const slotLength = Number.parseInt(available.slot_lenght, 10) || 45;
|
// Fallback for both spellings of slot length
|
||||||
const bookedStarts = new Set((available.slots_booked || []).map(function (entry) {
|
const slotLength = Number.parseInt(available.slot_length || available.slot_lenght, 10) || 45;
|
||||||
return String(entry.start || '').trim();
|
const clientsPerSlot = Number.parseInt(available.clients_per_slot, 10) || 1;
|
||||||
}).filter(Boolean));
|
const bookingsByTime = available.bookings_by_time || {};
|
||||||
|
|
||||||
function formatDateForInput(dateObj) {
|
function formatDateForInput(dateObj) {
|
||||||
const y = dateObj.getFullYear();
|
const y = dateObj.getFullYear();
|
||||||
@@ -402,6 +403,8 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fullyBookedSlots = [];
|
||||||
|
|
||||||
function buildCandidateSlots() {
|
function buildCandidateSlots() {
|
||||||
const slots = [];
|
const slots = [];
|
||||||
const allowedDates = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
|
const allowedDates = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
|
||||||
@@ -409,9 +412,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
|
|
||||||
spans.forEach(function (entry) {
|
spans.forEach(function (entry) {
|
||||||
const parsed = parseTimeSpanEntry(entry);
|
const parsed = parseTimeSpanEntry(entry);
|
||||||
if (!parsed) {
|
if (!parsed) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetDates = parsed.date ? [parsed.date] : allowedDates;
|
const targetDates = parsed.date ? [parsed.date] : allowedDates;
|
||||||
targetDates.forEach(function (date) {
|
targetDates.forEach(function (date) {
|
||||||
@@ -424,10 +425,19 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
let cursor = new Date(from);
|
let cursor = new Date(from);
|
||||||
while (addMinutes(cursor, slotLength) <= to) {
|
while (addMinutes(cursor, slotLength) <= to) {
|
||||||
const slotStart = formatDateForInput(cursor);
|
const slotStart = formatDateForInput(cursor);
|
||||||
if (!bookedStarts.has(slotStart)) {
|
const currentBookingsCount = bookingsByTime[slotStart] || 0;
|
||||||
|
|
||||||
|
// Check if the current individual timestamp still has capacity left
|
||||||
|
if (currentBookingsCount < clientsPerSlot) {
|
||||||
slots.push({
|
slots.push({
|
||||||
start: slotStart,
|
start: slotStart,
|
||||||
end: formatDateForInput(addMinutes(cursor, slotLength)),
|
end: formatDateForInput(addMinutes(cursor, slotLength)),
|
||||||
|
bookingsCount: currentBookingsCount
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fullyBookedSlots.push({
|
||||||
|
start: slotStart,
|
||||||
|
end: formatDateForInput(addMinutes(cursor, slotLength))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
cursor = addMinutes(cursor, slotLength);
|
cursor = addMinutes(cursor, slotLength);
|
||||||
@@ -442,27 +452,8 @@ 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 || ''));
|
||||||
|
|
||||||
// Show the requested UI time window from 08:15 to 20:00 and highlight available slots
|
let slotMinTime = '08:00:00';
|
||||||
let slotMinTime = '08:15:00';
|
|
||||||
let slotMaxTime = '20:00:00';
|
let slotMaxTime = '20:00:00';
|
||||||
if (candidateSlots.length > 0) {
|
|
||||||
const starts = candidateSlots.map(function (slot) {
|
|
||||||
return new Date(slot.start.replace(' ', 'T') + ':00');
|
|
||||||
}).filter(function (d) { return !Number.isNaN(d.getTime()); });
|
|
||||||
const ends = candidateSlots.map(function (slot) {
|
|
||||||
return new Date(slot.end.replace(' ', 'T') + ':00');
|
|
||||||
}).filter(function (d) { return !Number.isNaN(d.getTime()); });
|
|
||||||
|
|
||||||
// Keep the computed min/max for gap calculations below
|
|
||||||
var computedMinStart = null;
|
|
||||||
var computedMaxEnd = null;
|
|
||||||
if (starts.length > 0 && ends.length > 0) {
|
|
||||||
computedMinStart = starts[0];
|
|
||||||
computedMaxEnd = ends[0];
|
|
||||||
starts.forEach(function (d) { if (d < computedMinStart) computedMinStart = d; });
|
|
||||||
ends.forEach(function (d) { if (d > computedMaxEnd) computedMaxEnd = d; });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleStart = allDays[0] || String(available.date_start || '');
|
const visibleStart = allDays[0] || String(available.date_start || '');
|
||||||
const visibleEndExclusive = allDays.length > 0
|
const visibleEndExclusive = allDays.length > 0
|
||||||
@@ -497,14 +488,8 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
slotMinTime: slotMinTime,
|
slotMinTime: slotMinTime,
|
||||||
slotMaxTime: slotMaxTime,
|
slotMaxTime: slotMaxTime,
|
||||||
nowIndicator: true,
|
nowIndicator: true,
|
||||||
validRange: {
|
validRange: { start: visibleStart, end: visibleEndExclusive },
|
||||||
start: visibleStart,
|
visibleRange: { start: visibleStart, end: visibleEndExclusive },
|
||||||
end: visibleEndExclusive,
|
|
||||||
},
|
|
||||||
visibleRange: {
|
|
||||||
start: visibleStart,
|
|
||||||
end: visibleEndExclusive,
|
|
||||||
},
|
|
||||||
headerToolbar: {
|
headerToolbar: {
|
||||||
left: '',
|
left: '',
|
||||||
center: 'title',
|
center: 'title',
|
||||||
@@ -512,28 +497,22 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
},
|
},
|
||||||
events: [],
|
events: [],
|
||||||
eventDrop: function (info) {
|
eventDrop: function (info) {
|
||||||
if (info.event.id !== 'selected-slot') {
|
if (info.event.id !== 'selected-slot') return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const droppedStart = formatDateForInput(info.event.start);
|
const droppedStart = formatDateForInput(info.event.start);
|
||||||
if (!slotStartSet.has(droppedStart)) {
|
if (!slotStartSet.has(droppedStart)) {
|
||||||
info.revert();
|
info.revert();
|
||||||
window.alert('Dieser Zeitpunkt ist nicht als freier Slot verfügbar.');
|
window.alert('Dieser Zeitpunkt ist ausgebucht oder nicht verfügbar.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
applySelectedSlot(droppedStart);
|
applySelectedSlot(droppedStart);
|
||||||
},
|
},
|
||||||
eventClick: function (info) {
|
eventClick: function (info) {
|
||||||
const slotType = info.event.extendedProps ? info.event.extendedProps.slotType : '';
|
const slotType = info.event.extendedProps ? info.event.extendedProps.slotType : '';
|
||||||
if (slotType !== 'free') {
|
if (slotType !== 'free') return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
applySelectedSlot(info.event.extendedProps.slotStart || '');
|
applySelectedSlot(info.event.extendedProps.slotStart || '');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// No background greying: show full calendar skeleton and only mark possible slots
|
|
||||||
|
|
||||||
function updateSliderLabel() {
|
function updateSliderLabel() {
|
||||||
const dateList = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
|
const dateList = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
|
||||||
const idx = Number.parseInt(slider.value, 10) || 0;
|
const idx = Number.parseInt(slider.value, 10) || 0;
|
||||||
@@ -552,9 +531,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
selectedEvent = null;
|
selectedEvent = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedSlot) {
|
if (!selectedSlot) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = new Date(selectedSlot.replace(' ', 'T') + ':00');
|
const start = new Date(selectedSlot.replace(' ', 'T') + ':00');
|
||||||
const end = addMinutes(start, slotLength);
|
const end = addMinutes(start, slotLength);
|
||||||
@@ -573,13 +550,9 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
function getIcsDownloadUrl() {
|
function getIcsDownloadUrl() {
|
||||||
const base = {{ url_for('terminplaner.client_slot_calendar_export', appointment_id=appointment_id, tenant=tenant_id)|tojson }};
|
const base = {{ url_for('terminplaner.client_slot_calendar_export', appointment_id=appointment_id, tenant=tenant_id)|tojson }};
|
||||||
const url = new URL(base, window.location.origin);
|
const url = new URL(base, window.location.origin);
|
||||||
if (selectedSlot) {
|
if (selectedSlot) url.searchParams.set('start', selectedSlot);
|
||||||
url.searchParams.set('start', selectedSlot);
|
|
||||||
}
|
|
||||||
const name = String(clientNameInput.value || '').trim();
|
const name = String(clientNameInput.value || '').trim();
|
||||||
if (name) {
|
if (name) url.searchParams.set('name', name);
|
||||||
url.searchParams.set('name', name);
|
|
||||||
}
|
|
||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,14 +563,19 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No background gaps: keep full skeleton/grid visible
|
// Render Active/Available Slots with Live Seat Counts
|
||||||
|
|
||||||
// Add candidate free slots (blue)
|
|
||||||
candidateSlots.forEach(function (slot) {
|
candidateSlots.forEach(function (slot) {
|
||||||
const start = new Date(slot.start.replace(' ', 'T') + ':00');
|
const start = new Date(slot.start.replace(' ', 'T') + ':00');
|
||||||
const end = new Date(slot.end.replace(' ', 'T') + ':00');
|
const end = new Date(slot.end.replace(' ', 'T') + ':00');
|
||||||
|
|
||||||
|
let displayTitle = 'Freier Slot';
|
||||||
|
if (clientsPerSlot > 1) {
|
||||||
|
const remainingSeats = clientsPerSlot - slot.bookingsCount;
|
||||||
|
displayTitle += ' (' + remainingSeats + '/' + clientsPerSlot + ' frei)';
|
||||||
|
}
|
||||||
|
|
||||||
calendar.addEvent({
|
calendar.addEvent({
|
||||||
title: 'Freier Slot',
|
title: displayTitle,
|
||||||
start: start,
|
start: start,
|
||||||
end: end,
|
end: end,
|
||||||
color: '#0d6efd',
|
color: '#0d6efd',
|
||||||
@@ -610,46 +588,54 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
(available.slots_booked || []).forEach(function (booking) {
|
// Client View Optimization: Render solid red events ONLY if a slot is 100% full
|
||||||
const startStr = String(booking.start || '').trim();
|
fullyBookedSlots.forEach(function (slot) {
|
||||||
if (!startStr) {
|
const start = new Date(slot.start.replace(' ', 'T') + ':00');
|
||||||
return;
|
const end = new Date(slot.end.replace(' ', 'T') + ':00');
|
||||||
}
|
|
||||||
const start = new Date(startStr.replace(' ', 'T') + ':00');
|
|
||||||
const end = addMinutes(start, slotLength);
|
|
||||||
calendar.addEvent({
|
calendar.addEvent({
|
||||||
title: 'Gebucht' + (booking.name ? ' - ' + booking.name : ''),
|
title: 'Ausgebucht',
|
||||||
start: start,
|
start: start,
|
||||||
end: end,
|
end: end,
|
||||||
color: '#dc3545',
|
color: '#dc3545',
|
||||||
editable: false,
|
editable: false,
|
||||||
display: 'block',
|
display: 'block'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Admin Management View: Render detailed name blocks exclusively for management interaction
|
||||||
|
if (canViewBookingNames) {
|
||||||
|
(available.slots_booked || []).forEach(function (booking) {
|
||||||
|
const startStr = String(booking.start || '').trim();
|
||||||
|
if (!startStr) return;
|
||||||
|
const start = new Date(startStr.replace(' ', 'T') + ':00');
|
||||||
|
const end = addMinutes(start, slotLength);
|
||||||
|
calendar.addEvent({
|
||||||
|
title: 'Belegt: ' + (booking.name || 'Anonym'),
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
color: '#9e9e9e',
|
||||||
|
editable: false,
|
||||||
|
display: 'block',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event Listeners and Setup
|
||||||
form.addEventListener('submit', function (ev) {
|
form.addEventListener('submit', function (ev) {
|
||||||
if (!selectedSlotInput.value) {
|
if (!selectedSlotInput.value) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
window.alert('Bitte zuerst im Kalender einen freien Slot auswählen.');
|
window.alert('Bitte zuerst im Kalender einen freien Slot auswählen.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (allowImmediateSubmit) return;
|
||||||
if (allowImmediateSubmit) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
if (modal) {
|
if (modal) modal.show();
|
||||||
modal.show();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (confirmBookingOnlyBtn) {
|
if (confirmBookingOnlyBtn) {
|
||||||
confirmBookingOnlyBtn.addEventListener('click', function () {
|
confirmBookingOnlyBtn.addEventListener('click', function () {
|
||||||
allowImmediateSubmit = true;
|
allowImmediateSubmit = true;
|
||||||
if (modal) {
|
if (modal) modal.hide();
|
||||||
modal.hide();
|
|
||||||
}
|
|
||||||
form.submit();
|
form.submit();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -657,13 +643,9 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
if (confirmBookingWithIcsBtn) {
|
if (confirmBookingWithIcsBtn) {
|
||||||
confirmBookingWithIcsBtn.addEventListener('click', function () {
|
confirmBookingWithIcsBtn.addEventListener('click', function () {
|
||||||
const icsUrl = confirmBookingWithIcsBtn.getAttribute('data-ics-url') || getIcsDownloadUrl();
|
const icsUrl = confirmBookingWithIcsBtn.getAttribute('data-ics-url') || getIcsDownloadUrl();
|
||||||
if (icsUrl) {
|
if (icsUrl) window.open(icsUrl, '_blank');
|
||||||
window.open(icsUrl, '_blank');
|
|
||||||
}
|
|
||||||
allowImmediateSubmit = true;
|
allowImmediateSubmit = true;
|
||||||
if (modal) {
|
if (modal) modal.hide();
|
||||||
modal.hide();
|
|
||||||
}
|
|
||||||
form.submit();
|
form.submit();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -684,10 +666,8 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
slider.addEventListener('input', function () {
|
slider.addEventListener('input', function () {
|
||||||
const idx = Number.parseInt(slider.value, 10) || 0;
|
const idx = Number.parseInt(slider.value, 10) || 0;
|
||||||
updateSliderLabel();
|
updateSliderLabel();
|
||||||
if (allDays[idx]) {
|
if (allDays[idx] && calendar.view.type === 'timeGridDay') {
|
||||||
if (calendar.view.type === 'timeGridDay') {
|
calendar.gotoDate(allDays[idx]);
|
||||||
calendar.gotoDate(allDays[idx]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,10 @@
|
|||||||
<label for="slot_length" class="form-label fw-semibold">Slot-Länge in Minuten</label>
|
<label for="slot_length" class="form-label fw-semibold">Slot-Länge in Minuten</label>
|
||||||
<input type="number" id="slot_length" name="slot_length" class="form-control form-control-lg" min="1" value="45">
|
<input type="number" id="slot_length" name="slot_length" class="form-control form-control-lg" min="1" value="45">
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label fw-semibold text-primary">Nutzer pro Slot</label>
|
||||||
|
<input type="number" id="clients_per_slot" name="clients_per_slot" value="1">
|
||||||
|
</div>
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<label class="form-label fw-semibold text-primary">Automatisch berechnete Slots</label>
|
<label class="form-label fw-semibold text-primary">Automatisch berechnete Slots</label>
|
||||||
<div id="slots_amounts_display" class="form-control form-control-lg bg-primary-subtle text-primary fw-bold d-flex align-items-center">0</div>
|
<div id="slots_amounts_display" class="form-control form-control-lg bg-primary-subtle text-primary fw-bold d-flex align-items-center">0</div>
|
||||||
@@ -78,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"
|
||||||
@@ -149,8 +153,9 @@
|
|||||||
const defaultPauseEndInput = document.getElementById('default_pause_end');
|
const defaultPauseEndInput = document.getElementById('default_pause_end');
|
||||||
|
|
||||||
const slotLengthInput = document.getElementById('slot_length');
|
const slotLengthInput = document.getElementById('slot_length');
|
||||||
|
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;
|
||||||
@@ -198,16 +203,31 @@
|
|||||||
if (slotsAmountsDisplay) slotsAmountsDisplay.innerText = totalSlots + " Slots gesamt";
|
if (slotsAmountsDisplay) slotsAmountsDisplay.innerText = totalSlots + " Slots gesamt";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper utility in case your timeToMinutes handler doesn't catch empty strings safely
|
||||||
|
function safeTimeToMinutes(timeString) {
|
||||||
|
if (!timeString || typeof timeString !== 'string' || !timeString.includes(':')) {
|
||||||
|
return 0; // Return 0 instead of NaN for empty or missing values
|
||||||
|
}
|
||||||
|
const parts = timeString.split(':');
|
||||||
|
const hours = parseInt(parts[0], 10);
|
||||||
|
const minutes = parseInt(parts[1], 10);
|
||||||
|
if (isNaN(hours) || isNaN(minutes)) return 0;
|
||||||
|
return (hours * 60) + minutes;
|
||||||
|
}
|
||||||
|
|
||||||
// Berechnet die Slots basierend auf den konfigurierten Zeiten & Pausen
|
// Berechnet die Slots basierend auf den konfigurierten Zeiten & Pausen
|
||||||
function calculateSlots() {
|
function calculateSlots() {
|
||||||
if (!slotLengthInput || !slotsAmountsInput) return;
|
if (!slotLengthInput || !slotsAmountsInput || !clientsperslot) return;
|
||||||
|
|
||||||
const slotLength = parseInt(slotLengthInput.value, 10);
|
const slotLength = parseInt(slotLengthInput.value, 10);
|
||||||
if (isNaN(slotLength) || slotLength <= 0) {
|
if (isNaN(slotLength) || slotLength <= 0) {
|
||||||
updateSlotsDisplay(0);
|
updateSlotsDisplay(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIX 1: Make sure we parse the input VALUE, defaulting to 1 if empty or invalid
|
||||||
|
const multiplier = parseInt(clientsperslot.value, 10) || 1;
|
||||||
|
|
||||||
let totalSlots = 0;
|
let totalSlots = 0;
|
||||||
const rows = Array.from(daysContainer.querySelectorAll('[data-day-row]'));
|
const rows = Array.from(daysContainer.querySelectorAll('[data-day-row]'));
|
||||||
|
|
||||||
@@ -219,19 +239,20 @@
|
|||||||
|
|
||||||
if (!startTime || !endTime) return;
|
if (!startTime || !endTime) return;
|
||||||
|
|
||||||
const startMins = timeToMinutes(startTime);
|
// Using safety parsing to prevent missing values from generating NaN
|
||||||
const endMins = timeToMinutes(endTime);
|
const startMins = safeTimeToMinutes(startTime);
|
||||||
const pauseStartMins = timeToMinutes(pauseStart);
|
const endMins = safeTimeToMinutes(endTime);
|
||||||
const pauseEndMins = timeToMinutes(pauseEnd);
|
const pauseStartMins = safeTimeToMinutes(pauseStart);
|
||||||
|
const pauseEndMins = safeTimeToMinutes(pauseEnd);
|
||||||
|
|
||||||
if (endMins <= startMins) return; // Ungültige Zeit
|
if (isNaN(startMins) || isNaN(endMins) || endMins <= startMins) return;
|
||||||
|
|
||||||
// Wenn eine gültige Pause innerhalb der Start/Endzeit existiert
|
// Wenn eine gültige Pause innerhalb der Start/Endzeit existiert
|
||||||
if (pauseStartMins > 0 && pauseEndMins > 0 && pauseStartMins < pauseEndMins && pauseStartMins > startMins && pauseStartMins < endMins) {
|
if (pauseStartMins > 0 && pauseEndMins > 0 && pauseStartMins < pauseEndMins && pauseStartMins > startMins && pauseStartMins < endMins) {
|
||||||
// Segment 1 (Vor der Pause)
|
// Segment 1 (Vor der Pause)
|
||||||
const segment1 = pauseStartMins - startMins;
|
const segment1 = pauseStartMins - startMins;
|
||||||
totalSlots += Math.floor(segment1 / slotLength);
|
totalSlots += Math.floor(segment1 / slotLength);
|
||||||
|
|
||||||
// Segment 2 (Nach der Pause)
|
// Segment 2 (Nach der Pause)
|
||||||
const effectivePauseEnd = Math.min(pauseEndMins, endMins);
|
const effectivePauseEnd = Math.min(pauseEndMins, endMins);
|
||||||
const segment2 = endMins - effectivePauseEnd;
|
const segment2 = endMins - effectivePauseEnd;
|
||||||
@@ -244,7 +265,11 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
updateSlotsDisplay(totalSlots);
|
// FIX 2: Multiply by our safely parsed input value scalar
|
||||||
|
const totalSlotscomplete = totalSlots * multiplier;
|
||||||
|
|
||||||
|
// Safety check: if anything went wrong anyway, fallback to 0 instead of displaying NaN
|
||||||
|
updateSlotsDisplay(isNaN(totalSlotscomplete) ? 0 : totalSlotscomplete);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Synchronisiert das Textfeld und fügt den Break-Cut hinzu
|
// Synchronisiert das Textfeld und fügt den Break-Cut hinzu
|
||||||
@@ -363,33 +388,33 @@
|
|||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
const container = document.getElementById('custom-fields-container');
|
const container = document.getElementById('custom-fields-container');
|
||||||
|
|
||||||
// Monitor inputs inside the container using event delegation
|
// Monitor inputs inside the container using event delegation
|
||||||
container.addEventListener('input', function (event) {
|
container.addEventListener('input', function (event) {
|
||||||
// Only trigger if the user is typing inside our dynamic inputs
|
// Only trigger if the user is typing inside our dynamic inputs
|
||||||
if (event.target.classList.contains('custom-dynamic-input')) {
|
if (event.target.classList.contains('custom-dynamic-input')) {
|
||||||
const allInputs = container.querySelectorAll('.custom-dynamic-input');
|
const allInputs = container.querySelectorAll('.custom-dynamic-input');
|
||||||
const lastInput = allInputs[allInputs.length - 1];
|
const lastInput = allInputs[allInputs.length - 1];
|
||||||
|
|
||||||
// If the user typed something into the absolute last input field, spawn a new one
|
// If the user typed something into the absolute last input field, spawn a new one
|
||||||
if (lastInput.value.trim() !== '') {
|
if (lastInput.value.trim() !== '') {
|
||||||
createNewFieldRow(container);
|
createNewFieldRow(container);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper function to generate and append a clean new input row
|
// Helper function to generate and append a clean new input row
|
||||||
function createNewFieldRow(targetContainer) {
|
function createNewFieldRow(targetContainer) {
|
||||||
const rowDiv = document.createElement('div');
|
const rowDiv = document.createElement('div');
|
||||||
rowDiv.className = 'mb-3 custom-field-row';
|
rowDiv.className = 'mb-3 custom-field-row';
|
||||||
|
|
||||||
rowDiv.innerHTML = `
|
rowDiv.innerHTML = `
|
||||||
<input type="text"
|
<input type="text"
|
||||||
name="custom_fields"
|
name="custom_fields"
|
||||||
class="form-control form-control-lg custom-dynamic-input"
|
class="form-control form-control-lg custom-dynamic-input"
|
||||||
placeholder="Nächstes Zusatzfeld...">
|
placeholder="Nächstes Zusatzfeld...">
|
||||||
`;
|
`;
|
||||||
|
|
||||||
targetContainer.appendChild(rowDiv);
|
targetContainer.appendChild(rowDiv);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -409,10 +434,27 @@
|
|||||||
slotLengthInput.addEventListener('input', calculateSlots);
|
slotLengthInput.addEventListener('input', calculateSlots);
|
||||||
slotLengthInput.addEventListener('change', calculateSlots);
|
slotLengthInput.addEventListener('change', calculateSlots);
|
||||||
}
|
}
|
||||||
|
if (clientsperslot) {
|
||||||
|
clientsperslot.addEventListener('input', 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 %}
|
||||||
@@ -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>
|
||||||
@@ -105,4 +149,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user