Compare commits

...

18 Commits

Author SHA1 Message Date
Aiirondev_dev 10e2f7cc17 fur5ther fixes of to much information getting out 2026-06-27 17:00:40 +02:00
Aiirondev_dev 0f8c5a0fec slight adjustments for the security 2026-06-27 16:29:13 +02:00
Aiirondev_dev 091147ecfd slight security fixes to prevent malisios actors from getting access or retrieving unfit information 2026-06-27 16:07:26 +02:00
Aiirondev_dev 7bedfc1558 implementet encryption and decryption to the audit to provide a complete lak of context for a petential attacker 2026-06-27 13:45:21 +02:00
Aiirondev_dev 7e258e07ab some more savety processing 2026-06-27 13:28:49 +02:00
Aiirondev_dev 96245bb06b Fix of an legacy encryption Algorithm 2026-06-27 12:49:56 +02:00
Aiirondev_dev 245c3c19dd Fix of the SSRF Protection 2026-06-27 12:43:19 +02:00
Aiirondev_dev d76c69d836 SSRF Protection Implementation 2026-06-27 12:39:25 +02:00
Aiirondev_dev 9527fc10cf Fix of the Nav Bar for the Terminplaner Module 2026-06-27 11:31:47 +02:00
Aiirondev_dev 1122618a51 slight mispelled variable name 2026-06-27 11:05:35 +02:00
Aiirondev_dev 54d56fc463 link klorrektion 2026-06-27 10:53:28 +02:00
Aiirondev_dev 9da4ff3ec8 circuilar import fix 2026-06-27 10:08:12 +02:00
Aiirondev_dev 884464cfe5 pdf download for a client Brief 2026-06-27 10:00:10 +02:00
Aiirondev_dev 0be4e3ca58 return is beeing ignored to the submition 2026-06-27 00:35:44 +02:00
Aiirondev_dev e2aeea46f9 Fix of the encryption of when getting the upcoming events for a user 2026-06-27 00:16:59 +02:00
Aiirondev_dev 865fddd45b removal of unused encryption of user to compensate before right implementation of encryption 2026-06-27 00:08:41 +02:00
Aiirondev_dev 1835195d2f development debugging version 2026-06-26 23:58:15 +02:00
Aiirondev_dev 19a585b2ec Implementation of encryption for sensitive information that may be providet by the client, to ensure that all information important or not is safe 2026-06-26 23:39:05 +02:00
10 changed files with 723 additions and 390 deletions
+161 -172
View File
@@ -79,6 +79,7 @@ from Web.modules.inventarsystem.data_protection import (
decrypt_text,
encrypt_document_fields,
encrypt_soft_deleted_media_pack,
encrypt_text,
)
from Web.modules.terminplaner.blueprint import appoint_bp as terminplaner_bp
@@ -375,6 +376,20 @@ PERMISSION_ACTION_ENDPOINTS = {
'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"]
# Apply the configuration for general use throughout the app
APP_VERSION = __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'}:
return jsonify({'error': message}), 400
flash(message, 'error')
return redirect(request.referrer or url_for('home'))
return redirect(url_for('login'))
def _get_current_module(path):
"""Resolve the active UI module for navbar separation."""
@@ -657,7 +672,6 @@ def _get_current_module(path):
last_module = session.get('last_module')
if last_module and cfg.MODULES.is_enabled(last_module):
return last_module
if cfg.MODULES.is_enabled('inventory'):
return '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,
event_type=event_type,
actor=session.get('username', 'system'),
payload=payload,
payload=str(payload),
request_ip=request.remote_addr,
source='web',
)
@@ -938,7 +952,7 @@ def _create_notification(db, *, audience, notif_type, title, message, target_use
tag=f'notification-{notif_type}'
)
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':
_bump_notification_version('admin')
# Send push notification to all admins
@@ -1041,7 +1055,7 @@ def _get_cached_unread_status(username, is_admin=False):
if cached:
return json.loads(cached), version_tag
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
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))
return
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:
_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
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)
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:
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
@@ -2132,7 +2146,7 @@ def _upload_student_cards_excel():
created_total += 1
except Exception as 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'))
finally:
client.close()
@@ -2821,8 +2835,8 @@ def catch_all_files(filename):
# If we get here, the file wasn't found
return Response(f"File {filename} not found", status=404)
except Exception as e:
print(f"Error in catch-all route for {filename}: {str(e)}")
return Response(f"Error serving file: {str(e)}", status=500)
app.logger.error(f"Error in catch-all route for {filename}: {str(e)}")
return Response(f"Error serving file {filename}", status=500)
"""-------------------------------------------------------------Main Views-----------------------------------------------------------------------------"""
@@ -3336,7 +3350,7 @@ def api_library_items():
}), 200
except Exception as 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'])
@@ -3430,7 +3444,7 @@ def api_library_scan_action():
'channel': 'library_scan',
'item_id': item_id,
'item_name': item_doc.get('Name', ''),
'borrower': borrower_name,
'borrower':borrower_name,
'student_card_id': student_card_id,
'borrow_duration_days': borrow_duration_days,
}
@@ -3599,10 +3613,9 @@ def api_item_detail(item_id):
"""
client.close()
return detail_html, 200
return detail_html, 200
except Exception as 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'])
@@ -4216,7 +4229,8 @@ def student_card_barcode_download():
download_name=f'schuelerausweise_all_{datetime.datetime.now().strftime("%Y%m%d_%H%M%S")}.pdf'
)
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'))
@@ -4388,7 +4402,8 @@ def student_card_single_barcode_download(card_id):
download_name=f'ausweis_{card["AusweisId"]}.pdf'
)
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'))
@@ -4409,19 +4424,17 @@ def login():
ctx = get_tenant_context()
current_tenant_id = ctx.tenant_id if ctx else None
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"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"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"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:
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')
return redirect(url_for('login'))
user = us.check_nm_pwd(username, password)
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
is_admin_user = bool(user.get('Admin', False))
session['admin'] = is_admin_user
@@ -4442,7 +4455,7 @@ def login():
else:
return redirect(url_for('home'))
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')
get_flashed_messages()
return render_template('login.html')
@@ -4727,7 +4740,7 @@ def get_items():
'has_more': pagination_requested and ((offset + count) < total_count)
})
except Exception as e:
return jsonify({'items': [], 'error': str(e)}), 500
return jsonify({'items': []}), 500
finally:
if client:
client.close()
@@ -4744,7 +4757,8 @@ def get_item_json(id):
item['_id'] = str(item['_id'])
return jsonify(item)
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')
@@ -4818,7 +4832,7 @@ def get_bookings():
return jsonify({'ok': True, 'bookings': result})
except Exception as e:
return jsonify({'ok': False, 'error': str(e), 'bookings': []}), 500
return jsonify({'ok': False, 'bookings': []}), 500
finally:
if client:
client.close()
@@ -4908,7 +4922,7 @@ def get_user_appointments():
return jsonify({'ok': True, 'bookings': result})
except Exception as e:
return jsonify({'ok': False, 'error': str(e), 'bookings': []}), 500
return jsonify({'ok': False, 'bookings': []}), 500
finally:
if client:
client.close()
@@ -4952,7 +4966,8 @@ def api_booking_conflicts():
client.close()
return jsonify({'conflicts': result, 'count': len(result)})
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)."""
def _ensure_session_favs():
@@ -5067,7 +5082,8 @@ def debug_favorites():
try:
db_favs = us.get_favorites(username)
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))
return jsonify({'ok': True, 'user': username, 'session': session_favs, 'db': db_favs, 'merged': merged})
@@ -5106,7 +5122,7 @@ def upload_item():
# Log mobile request for debugging
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:
# Strip whitespace from all text fields
@@ -5175,7 +5191,7 @@ def upload_item():
except json.JSONDecodeError as e:
app.logger.error(f"Error parsing mobile data: {str(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)
if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400
@@ -5339,7 +5355,7 @@ def upload_item():
# Create a structured log entry for upload session
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
for directory in [app.config['UPLOAD_FOLDER']]:
@@ -5538,7 +5554,7 @@ def upload_item():
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} Error type: {type(fallback_err).__name__}")
traceback.print_exc()
error_count += 1
continue
else:
@@ -5646,7 +5662,7 @@ def upload_item():
except Exception as webp_err:
app.logger.error(f"PNG DEBUG: {image_log_prefix} Final WebP conversion failed: {str(webp_err)}")
traceback.print_exc()
error_count += 1
continue
@@ -5705,7 +5721,7 @@ def upload_item():
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} 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})")
@@ -5755,7 +5771,6 @@ def upload_item():
app.logger.warning(f"{image_log_prefix} Optimization failed or returned no file")
except Exception as 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
@@ -5767,7 +5782,6 @@ def upload_item():
except Exception as e:
app.logger.error(f"{image_log_prefix} Unexpected error: {str(e)}")
traceback.print_exc()
error_count += 1
# Continue with the next image
@@ -5904,7 +5918,7 @@ def upload_item():
app.logger.error(f"Error generating optimized versions for {new_filename}: {e}")
# If optimization fails, at least keep the original file
result = {'original': new_filename}
traceback.print_exc()
# If we didn't find the image, use a placeholder
else:
@@ -5940,7 +5954,7 @@ def upload_item():
app.logger.info(f"Processed image {i+1}/{original_count}: {new_filename}")
except Exception as e:
app.logger.error(f"Error processing image {i+1}/{original_count} ({dup_img}): {str(e)}")
traceback.print_exc()
error_count += 1
# Log placeholder usage
@@ -6093,7 +6107,7 @@ def duplicate_item():
# Log mobile duplication for debugging
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
original_item_id = request.form.get('original_item_id')
@@ -6174,7 +6188,7 @@ def duplicate_item():
except Exception as e:
print(f"Error in duplicate_item: {e}")
traceback.print_exc()
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))
archived_files = int((delete_result.get('archive') or {}).get('archived_files', 0))
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
archived_files = 0
finally:
@@ -6459,7 +6473,7 @@ def bulk_delete_items():
'group_item_ids': result.get('group_item_ids', []),
})
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
finally:
if client:
@@ -7190,9 +7204,9 @@ def zurueckgeben(id):
)
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)
flash(f'Element zurückgegeben, aber ein Fehler ist aufgetreten: {str(e)}', 'warning')
flash('Element zurückgegeben, aber ein Fehler ist aufgetreten', 'warning')
else:
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()
return jsonify({'ok': True, 'bookings': bookings})
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>')
@@ -7315,7 +7329,7 @@ def get_planned_bookings_public(item_id):
client.close()
return jsonify({'ok': True, 'bookings': bookings})
except Exception as e:
return jsonify({'ok': False, 'error': str(e)}), 500
return jsonify({'ok': False}), 500
@app.route('/check_availability')
@@ -7396,7 +7410,7 @@ def check_availability():
client.close()
return jsonify({'ok': True, 'available': len(conflicts) == 0, 'conflicts': conflicts})
except Exception as e:
return jsonify({'ok': False, 'error': str(e)}), 500
return jsonify({'ok': False}), 500
# def create_qr_code(id):
@@ -7494,8 +7508,9 @@ def plan_booking():
if booking_type == 'single':
end_date = start_date
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
item = it.get_item(item_id)
if not item:
@@ -7567,16 +7582,15 @@ def plan_booking():
}
else:
# All failed
return {"success": False, "errors": errors}, 400
return {"success": False}, 500
else:
# All succeeded
return {"success": True, "booking_ids": booking_ids}
except Exception as e:
import traceback
print(f"Error in plan_booking: {e}")
traceback.print_exc()
return {"success": False, "error": f"Serverfehler: {str(e)}"}, 500
app.logger.error(f"Error in plan_booking: {e}")
return {"success": False, "error": f"Fehler beim Planen der Buchung"}, 500
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)})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
return jsonify({'success': False})
@app.route('/cancel_booking/<id>', methods=['POST'])
def cancel_booking(id):
@@ -7722,9 +7736,7 @@ def terminplan():
return render_template('terminplan.html', school_periods=SCHOOL_PERIODS)
except Exception as e:
import traceback
print(f"Error rendering terminplan: {e}")
traceback.print_exc()
app.logger.error(f"Error rendering terminplan: {e}")
flash('Ein Fehler ist beim Anzeigen des Kalenders aufgetreten.', 'error')
return redirect(url_for('home'))
@@ -7910,15 +7922,17 @@ def delete_user():
client.close()
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
try:
us.delete_user(username)
flash(f'Benutzer {username} erfolgreich gelöscht', 'success')
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'))
@@ -8016,7 +8030,7 @@ def admin_verify_audit_chain():
status_code = 200 if result.get('ok') else 409
return jsonify(result), status_code
except Exception as exc:
return jsonify({'ok': False, 'error': str(exc)}), 500
return jsonify({'ok': False, 'error': 'Internal server error'}), 500
finally:
if client:
client.close()
@@ -8035,23 +8049,14 @@ def admin_audit_dashboard():
al.ensure_audit_indexes(db)
verify_result = al.verify_audit_chain(db)
audit_rows = list(
db['audit_log'].find(
{},
{
'chain_index': 1,
'event_type': 1,
'actor': 1,
'source': 1,
'ip': 1,
'timestamp': 1,
'created_at': 1,
'entry_hash': 1,
'prev_hash': 1,
'payload': 1,
}
).sort('chain_index', -1).limit(200)
)
audit_rows = list(db['audit_log'].find(...).sort('chain_index', -1).limit(200))
# DEC_START: Decrypt the sensitive fields for display
for row in audit_rows:
if "payload" in row:
# decrypt_document_fields acts in-place
decrypt_document_fields(row["payload"], SENSITIVE_AUDIT_FIELDS)
# DEC_END
return render_template(
'admin_audit.html',
@@ -8096,28 +8101,18 @@ def admin_audit_export_pdf_official():
])
)
audit_rows = list(
db['audit_log'].find(
{},
{
'chain_index': 1,
'event_type': 1,
'actor': 1,
'source': 1,
'ip': 1,
'timestamp': 1,
'created_at': 1,
'entry_hash': 1,
'prev_hash': 1,
'payload': 1,
}
).sort('chain_index', -1).limit(limit)
)
audit_rows = list(db['audit_log'].find(...).sort('chain_index', -1).limit(limit))
# DEC_START: Decrypt sensitive fields for the PDF report
for row in audit_rows:
if "payload" in row:
decrypt_document_fields(row["payload"], SENSITIVE_AUDIT_FIELDS)
# DEC_END
# Get school information from settings or use defaults
school_info = _get_school_info_for_export()
# Generate PDF
# Generate PDF with now-decrypted audit_rows
pdf_content = pdf_export.generate_audit_pdf(
verify_result=verify_result,
event_counts=event_counts,
@@ -8125,7 +8120,7 @@ def admin_audit_export_pdf_official():
export_type='official',
school_info=school_info
)
response = make_response(pdf_content)
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'
@@ -8133,7 +8128,7 @@ def admin_audit_export_pdf_official():
except Exception as 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:
if client:
client.close()
@@ -8196,7 +8191,7 @@ def admin_image_cache_stats():
})
except Exception as 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'])
@@ -8245,7 +8240,7 @@ def admin_image_cache_cleanup():
})
except Exception as 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-------------------------------------------------------"""
@@ -8314,7 +8309,8 @@ def admin_reset_borrowing(borrow_id):
client.close()
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'))
@@ -8928,10 +8924,11 @@ def admin_reset_user_password():
# Reset the password
try:
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:
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'))
@@ -9431,7 +9428,8 @@ def search_word(word):
return jsonify({"success": True, "response": list(id_set)})
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):
"""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
except Exception as e:
print(f"Error fetching book data: {e}")
return jsonify({"error": f"Failed to fetch book information: {str(e)}"}), 500
app.logger.error(f"Error fetching book data: {e}")
return jsonify({"error": f"Failed to fetch book information"}), 500
@app.route('/download_book_cover', methods=['POST'])
def download_book_cover():
"""
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:
return jsonify({"error": "Not authorized"}), 403
@@ -9759,17 +9754,17 @@ def download_book_cover():
if parsed_url.scheme != 'https' or not parsed_url.netloc:
return jsonify({"error": "Only public HTTPS URLs are allowed"}), 400
hostname = parsed_url.hostname or ''
if not _is_public_host(hostname):
return jsonify({"error": "Target host is not allowed"}), 400
# 2. SSRF Protection: Strict Allowlist Check
if parsed_url.netloc not in ALLOWED_COVER_DOMAINS:
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)
if response.status_code != 200:
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', '')
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"
}), 400
# Check content length header
content_length = response.headers.get('Content-Length')
if content_length:
try:
@@ -9786,14 +9782,10 @@ def download_book_cover():
except ValueError:
pass
# Generate a fully unique filename using UUID
import uuid
import time
# Generate a fully unique filename
unique_id = str(uuid.uuid4())
timestamp = time.strftime("%Y%m%d%H%M%S")
# Use appropriate extension based on content type
extension = '.jpg' # default
if 'image/png' in content_type.lower():
extension = '.png'
@@ -9801,15 +9793,16 @@ def download_book_cover():
extension = '.gif'
filename = f"book_cover_{unique_id}_{timestamp}{extension}"
# Save the image to uploads folder
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:
written = 0
for chunk in response.iter_content(chunk_size=8192):
written += len(chunk)
if written > 5 * 1024 * 1024:
# Clean up the partial file before aborting
os.remove(filepath)
return jsonify({"error": "Image is too large"}), 413
f.write(chunk)
@@ -9819,10 +9812,13 @@ def download_book_cover():
"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:
print(f"Error downloading book cover: {e}")
return jsonify({"error": f"Failed to download image: {str(e)}"}), 500
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"}), 500
"""
@app.route('/proxy_image')
def proxy_image():
@@ -9881,8 +9877,8 @@ def proxy_image():
}
)
except Exception as e:
print(f"Error in proxy_image: {e}")
return jsonify({"error": f"Error fetching image: {str(e)}"}), 500
app.logger.error(f"Error in proxy_image: {e}")
return jsonify({"error": f"Error fetching image"}), 500
"""
@@ -9978,11 +9974,6 @@ def my_borrowed_items():
'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
active_items = []
planned_items = []
@@ -10188,7 +10179,7 @@ def mark_all_notifications_read():
if result.modified_count > 0:
_bump_notification_version(f'user:{username}')
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:
if client:
client.close()
@@ -10267,7 +10258,7 @@ def notifications_unread_status():
etag_value = _build_unread_status_etag(version_tag, payload)
return _build_cached_json_response(payload, etag_value)
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
finally:
if client:
@@ -10735,8 +10726,8 @@ def schedule_appointment():
if has_conflict:
return jsonify({'success': False, 'message': 'Termin kollidiert mit bestehender Buchung'}), 409
except Exception as e:
print(f"Error checking for booking conflicts: {e}")
return jsonify({'success': False, 'message': f'Fehler beim Prüfen der Verfügbarkeit: {str(e)}'}), 500
app.logger.error(f"Error checking for booking conflicts: {e}")
return jsonify({'success': False, 'message': f'Fehler beim Prüfen der Verfügbarkeit'}), 500
# Check if the appointment should already be active
now = datetime.datetime.now()
@@ -10807,8 +10798,8 @@ def schedule_appointment():
if not appointment_id:
return jsonify({'success': False, 'message': 'Termin konnte nicht erstellt werden'}), 500
except Exception as e:
print(f"Error creating booking: {e}")
return jsonify({'success': False, 'message': f'Fehler beim Erstellen des Termins: {str(e)}'}), 500
app.logger.error(f"Error creating booking: {e}")
return jsonify({'success': False, 'message': f'Fehler beim Erstellen des Termins'}), 500
# If we got this far, we have a valid appointment_id
try:
@@ -10842,14 +10833,14 @@ def schedule_appointment():
return jsonify({'success': False, 'message': 'Element konnte nicht mit Termininformationen aktualisiert werden'}), 500
except Exception as e:
print(f"Error updating item with appointment info: {e}")
return jsonify({'success': False, 'message': f'Fehler beim Aktualisieren des Elements: {str(e)}'}), 500
app.logger.error(f"Error updating item with appointment info: {e}")
return jsonify({'success': False, 'message': f'Fehler beim Aktualisieren des Elements'}), 500
except Exception as e:
print(f"Error creating appointment: {e}")
app.logger.error(f"Error creating appointment: {e}")
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'])
def cancel_ausleihung_route(id):
@@ -10923,16 +10914,15 @@ def cancel_ausleihung_route(id):
next_appt = item_doc.get('NextAppointment', {})
if next_appt and str(next_appt.get('appointment_id')) == str(id):
cleared = it.clear_item_next_appointment(item_id)
print(f"Cleared NextAppointment for item {item_id}: {cleared}")
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:
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')
except Exception as e:
print(f"Error canceling ausleihung: {e}")
flash(f'Fehler: {str(e)}', 'error')
app.logger.warning(f"Error canceling ausleihung: {e}")
flash(f'Fehler', 'error')
return redirect(url_for('my_borrowed_items'))
@@ -10963,22 +10953,22 @@ def reset_item(id):
if result['success']:
return jsonify({
'success': True,
'message': result['message'],
'message': 'Item reset successfully',
'details': result.get('details', {})
})
else:
return jsonify({
'success': False,
'message': result['message']
'message': 'Failed to reset item'
}), 400
except Exception as e:
print(f"Error in reset_item route: {e}")
import traceback
traceback.print_exc()
return jsonify({
'success': False,
'error': f'Serverfehler: {str(e)}'
'error': f'Serverfehler'
}), 500
# New image and video optimization functions
@@ -11124,7 +11114,6 @@ def create_image_thumbnail(image_path, thumbnail_path, size, debug_prefix=""):
return True
except Exception as e:
print(f"Error creating image thumbnail for {image_path}: {str(e)}")
return False
@@ -11165,11 +11154,11 @@ def create_video_thumbnail(video_path, thumbnail_path, size):
return success
else:
print(f"ffmpeg failed for {video_path}: {result.stderr}")
app.logger.error(f"ffmpeg failed for {video_path}: {result.stderr}")
return False
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
@@ -11396,7 +11385,7 @@ def generate_optimized_versions(filename, max_original_width=500, target_size_kb
except Exception as e:
app.logger.error(f"{log_prefix} Failed to process image: {str(e)}")
traceback.print_exc()
# 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):
try:
@@ -11425,7 +11414,7 @@ def generate_optimized_versions(filename, max_original_width=500, target_size_kb
except Exception as 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 os.path.exists(original_path):
@@ -11563,10 +11552,10 @@ def log_mobile_action(action, request, success=True, details=None):
if details:
message += f" - Details: {details}"
if success:
app.logger.info(message)
else:
app.logger.error(message)
# if success:
# app.logger.info(message)
# else:
# app.logger.error(message)
# Add explicit static file routes to handle CSS serving issues
@app.route('/static/<path:filename>')
@@ -11661,7 +11650,7 @@ def cleanup_old_optimized_images(max_age_days=30):
}
except Exception as 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'])
@@ -11706,7 +11695,7 @@ def log_mobile_issue():
return jsonify({'success': True})
except Exception as 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):
"""
@@ -11848,7 +11837,7 @@ def subscribe_to_push():
success = pn.save_push_subscription(username, subscription)
if success:
app.logger.info(f'Push subscription saved for {username}')
app.logger.info(f'Push subscription saved for {encrypt_text(username)}')
return jsonify({
'success': True,
'message': 'Successfully subscribed to push notifications'
@@ -11858,7 +11847,7 @@ def subscribe_to_push():
except Exception as 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'])
@@ -11885,7 +11874,7 @@ def unsubscribe_from_push():
success = pn.remove_push_subscription(username, endpoint)
if success:
app.logger.info(f'Push subscription removed for {username}')
app.logger.info(f'Push subscription removed for {encrypt_text(username)}')
return jsonify({
'success': True,
'message': 'Successfully unsubscribed from push notifications'
@@ -11895,7 +11884,7 @@ def unsubscribe_from_push():
except Exception as 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'])
@@ -11929,7 +11918,7 @@ def get_push_subscriptions():
except Exception as 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'])
@@ -11954,7 +11943,7 @@ def get_vapid_key():
except Exception as 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'])
@@ -11993,4 +11982,4 @@ def test_push_notification():
except Exception as e:
app.logger.error(f'Error sending test push: {e}')
return jsonify({'success': False, 'error': str(e)}), 500
return jsonify({'success': False}), 500
+154 -77
View File
@@ -18,9 +18,11 @@ Collection Structure:
- Status fields: slots_used_by
"""
import Web.modules.database.settings as cfg
import Web.modules.inventarsystem.data_protection as dp
from Web.modules.database.settings import MongoClient
from bson.objectid import ObjectId
import datetime
import ast
def _get_tenant_db(client):
@@ -37,8 +39,47 @@ def _active_record_query(extra_query=None):
base_query.update(extra_query)
return base_query
def _decrypt_appointment(item):
"""Helper function to safely decrypt appointment fields back to their original types."""
if not item:
return item
try:
if 'user' in item and item['user']:
item['user'] = dp.decrypt_text(item['user'])
if 'note' in item and item['note']:
item['note'] = dp.decrypt_text(item['note'])
if 'title' in item and item['title']:
item['title'] = dp.decrypt_text(item['title'])
if 'mail' in item and item['mail']:
decrypted_mail = dp.decrypt_text(item['mail'])
try:
item['mail'] = ast.literal_eval(decrypted_mail)
except Exception:
item['mail'] = decrypted_mail
if 'custom_fields' in item and item['custom_fields']:
item['custom_fields'] = [dp.decrypt_text(field) for field in item['custom_fields']]
if 'slots_booked' in item and item['slots_booked']:
# If it's a string, it was encrypted during an update execution
if isinstance(item['slots_booked'], str):
decrypted_slots = dp.decrypt_text(item['slots_booked'])
try:
item['slots_booked'] = ast.literal_eval(decrypted_slots)
except Exception:
item['slots_booked'] = decrypted_slots
except Exception as e:
print(f"Error during decryption: {e}")
return item
def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False, title: str="", custom_fields: list = (), clients_p_slot: int=1):
client = None
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
@@ -50,62 +91,53 @@ def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
'time_span': time_span,
'slots': slots,
'slot_lenght': slot_lenght,
'user': user,
'mail': mail,
'note': note,
'title': title,
'custom_fields': custom_fields,
'user': dp.encrypt_text(user.strip()),
'mail': dp.encrypt_text(str(mail)),
'note': dp.encrypt_text(note),
'title': dp.encrypt_text(title),
'custom_fields': [dp.encrypt_text(str(field)) for field in custom_fields],
'calendar_enabled': bool(calendar_enabled),
'clients_per_slot': clients_p_slot,
'slots_booked': [], # -> [(start_time, (names),(custom1, custom2,...)), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable
'slots_booked': [], # -> [(start_time, (names),(custom1, custom2,...)), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable
'Created': datetime.datetime.now(),
'LastUpdated': datetime.datetime.now()
}
result = items.insert_one(item)
return result.inserted_id
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):
"""
Retrieve a specific appointment by its ID.
Args:
id (str): ID of the appointsment to retrieve
Returns:
dict: The appointment document or None if not found
"""
"""Retrieve a specific appointment by its ID and decrypt it."""
client = None
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
items = db['appointments']
item = items.find_one(_active_record_query({'_id': ObjectId(id)}))
client.close()
return item
return _decrypt_appointment(item)
except Exception as e:
print(f"Error retrieving item: {e}")
return None
finally:
if client:
client.close()
def update(id,slots_used: list):
"""
Update an existing appointment.
Args:
id (str): ID of the item to update
Returns:
bool: True if successful, False otherwise
"""
def update(id, slots_used: list):
"""Update an existing appointment's booked slots securely."""
client = None
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
items = db['appointments']
update_data = {
'slots_booked': slots_used,
'slots_booked': dp.encrypt_text(str(slots_used)),
'LastUpdated': datetime.datetime.now()
}
@@ -114,53 +146,81 @@ def update(id,slots_used: list):
{'$set': update_data}
)
client.close()
return result.modified_count > 0
except Exception as e:
print(f"Error updating item: {e}")
return False
finally:
if client:
client.close()
def remove_slot(id, date_start_time, name):
"""
Remove a booked slot from an appointment's `slots_booked`.
Args:
id (str): Appointment ID
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
Remove a booked slot from an appointment's encrypted `slots_booked` list.
Because the array is stored as an encrypted string blob, we must decrypt,
modify it in Python, and re-encrypt it.
"""
client = None
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
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(
{'_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
except Exception as e:
print(f"Error removing slot: {e}")
return False
finally:
if client:
client.close()
def remove(id):
"""
Soft-delete an appointment by setting its `Deleted` flag.
Args:
id (str): Appointment ID
Returns:
bool: True if the appointment was marked deleted, False otherwise
Hard-delete an appointment plan by its ID.
(Note: If your docstring mentions a soft-delete 'Deleted' flag,
change items.delete_one to items.update_one with {'$set': {'Deleted': True}})
"""
client = None
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
@@ -168,70 +228,87 @@ def remove(id):
result = items.delete_one({'_id': ObjectId(id)})
client.close()
return result.deleted_count > 0
except Exception as e:
print(f"Error removing appointment: {e}")
return False
finally:
if client:
client.close()
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:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
items = db['appointments']
today = datetime.date.today().strftime('%Y-%m-%d')
removed_count = 0
cursor = items.find(
result = items.delete_many(
_active_record_query(
{
'date_end': {'$lt': today},
}
)
).sort('date_start', 1)
)
for item in cursor:
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
return result.deleted_count > 0
except Exception as e:
print(f"Error removing appointment: {e}")
print(f"Error cleaning up finished appointments: {e}")
return False
finally:
if client:
client.close()
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:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
items = db['appointments']
today = datetime.date.today().strftime('%Y-%m-%d')
target_user = str(user or '').strip()
cursor = items.find(
_active_record_query(
{
'user': str(user or '').strip(),
'date_end': {'$gte': today},
}
)
_active_record_query({
'date_end': {'$gte': today},
})
).sort('date_start', 1)
results = []
for item in cursor:
item['_id'] = str(item.get('_id'))
results.append(item)
decrypted_item = _decrypt_appointment(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)):
break
client.close()
return results
except Exception as e:
print(f"Error retrieving upcoming appointments: {e}")
return []
finally:
if client:
client.close()
+36 -13
View File
@@ -10,6 +10,7 @@ import hashlib
import json
import random
import time
from Web.modules.inventarsystem.data_protection import encrypt_document_fields, decrypt_document_fields
from pymongo.errors import DuplicateKeyError
@@ -24,21 +25,34 @@ def _entry_hash(prev_hash, payload):
base = f"{prev_hash}|{_stable_json(payload)}"
return hashlib.sha256(base.encode("utf-8")).hexdigest()
def append_audit_event(db, event_type, actor, payload, request_ip=None, source="web", max_retries=5):
def get_decrypted_audit_logs(db, query=None, decrypt_fields=None):
"""
Append an audit event to a tamper-evident chain.
Retrieve and decrypt audit logs for analysis.
Args:
db: MongoDB database handle.
event_type (str): Event category.
actor (str): User/system who performed the action.
payload (dict): Event details.
request_ip (str, optional): Request origin.
source (str): Source subsystem.
query (dict): MongoDB query filter.
decrypt_fields (list): Fields within the 'payload' that should be decrypted.
"""
logs = db["audit_log"]
cursor = logs.find(query or {}).sort("chain_index", 1)
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"]
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
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 = {
"event_type": event_type,
"actor": actor or "system",
"source": source,
"ip": request_ip or "",
"payload": payload or {},
"payload": event_payload,
"timestamp": timestamp.isoformat() + "Z",
}
# 3. Hash the payload (which now contains encrypted values)
entry_hash = _entry_hash(prev_hash, entry_payload)
entry = {
+2 -3
View File
@@ -21,7 +21,7 @@ def log_status_change(ausleihung_id, old_status, new_status, user=None):
ausleihung_id: Die ID der Ausleihung
old_status: Der alte Status
new_status: Der neue Status
user: Der Benutzer, der die Änderung vorgenommen hat (optional)
"""
try:
# 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
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:
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
except Exception as e:
+9 -7
View File
@@ -8,6 +8,7 @@ import Web.modules.emailservice.email as mail_service
import Web.modules.database.termine as termin
import Web.modules.database.settings as cfg
from Web.tenant import get_tenant_context
import Web.modules.inventarsystem.data_protection as dp
def _resolve_public_base_url() -> str:
@@ -99,8 +100,11 @@ def build_calendar_ics(appointment_id: str) -> str | None:
return None
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 = [
f"Buchungslink: {link}",
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))
if note:
description_lines.append('Notiz: ' + str(note))
if titel:
if titel and not summary == titel:
description_lines.append('Titel: ' + str(titel))
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'URL:{_escape_ics_text(link)}',
f'DTSTART;VALUE=DATE:{_format_ics_date(start_date)}',
f'DTEND;VALUE=DATE:{_format_ics_date(end_date + timedelta(days=1))}',
f'Titel:{_escape_ics_text(titel)}',
f'DTEND;VALUE=DATE:{_format_ics_date(end_date + datetime.timedelta(days=1))}',
'END:VEVENT',
'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"
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_end = end_dt.strftime('%Y%m%dT%H%M%S')
@@ -405,7 +408,6 @@ def get_available(id):
def get_available_user(id):
return get_available(id)
def get_user_upcoming_events(user: str, limit: int = 25) -> list[dict]:
user_name = str(user or '').strip()
if not user_name:
+183 -5
View File
@@ -1,17 +1,57 @@
from flask import Blueprint, render_template, request, session, url_for, redirect, flash
from flask import Response
from flask import Blueprint, render_template, request, session, url_for, redirect, flash, make_response, Response, send_file
import Web.modules.terminplaner.backend_server as appointment_service
import Web.modules.database.settings as cfg
import Web.modules.database.termine as termin
import Web.modules.database.user as us
from Web.modules.terminplaner.backend_server import _resolve_public_base_url
import csv
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
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():
if not cfg.MODULES.is_enabled('terminplan'):
flash('Der Terminplaner ist deaktiviert.', 'info')
@@ -175,7 +215,8 @@ def client(appointment_id):
current_user=session.get('username', ''),
tenant_id=_current_tenant_id(),
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):
@@ -199,7 +240,8 @@ def client(appointment_id):
tenant_id=_current_tenant_id(),
can_view_booking_names=can_view_booking_names,
custom_fields=custom_fields,
appointment_item=appointment_item
appointment_item=appointment_item,
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan')
)
@@ -316,6 +358,7 @@ def configure():
add_to_calendar=add_to_calendar,
email_service_enabled=cfg.EMAIL_ENABLED,
title=title,
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan')
)
return render_template(
@@ -326,6 +369,7 @@ def configure():
add_to_calendar=False,
email_service_enabled=cfg.EMAIL_ENABLED,
title=None,
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan')
)
@@ -364,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'
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('/')
def main():
guard = _require_module_enabled()
@@ -374,10 +550,12 @@ def main():
upcoming_events = appointment_service.get_user_upcoming_events(current_user) if current_user else []
tenant_id = _current_tenant_id()
return render_template(
'terminplaner.html',
school_periods=cfg.SCHOOL_PERIODS,
current_user=current_user,
upcoming_events=upcoming_events,
tenant_id=tenant_id,
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan')
)
+71 -110
View File
@@ -13,6 +13,7 @@ import logging
import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient
from Web.modules.inventarsystem.data_protection import encrypt_text, decrypt_text
logger = logging.getLogger(__name__)
@@ -44,16 +45,22 @@ if not VAPID_PUBLIC_KEY or not VAPID_PRIVATE_KEY:
serialization.PublicFormat.UncompressedPoint
)
VAPID_PUBLIC_KEY = b64urlencode(raw_pub)
VAPID_PUBLIC_KEY = b64urlencode(raw_pub).decode('utf-8')
VAPID_PRIVATE_KEY = VAPID_PRIVATE_PEM
except Exception as e:
logger.error(f'Could not load or generate VAPID keys: {e}')
# 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
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):
"""Get MongoDB push subscriptions collection"""
if db is None:
@@ -64,71 +71,68 @@ def get_push_subscriptions_collection(db=None):
def get_user_subscriptions(username):
"""
Get all active push subscriptions for a user
Args:
username (str): Username
Returns:
list: List of subscription documents
Get all active push subscriptions for a user, decrypting data on the fly.
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
subs_col = get_push_subscriptions_collection(db)
subscriptions = list(subs_col.find({
'Username': username,
# Query using the deterministic hash, NOT the encrypted text directly
user_hash = _get_username_hash(username)
encrypted_subscriptions = list(subs_col.find({
'UsernameHash': user_hash,
'IsActive': True
}))
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:
logger.error(f'Error getting push subscriptions for {username}: {e}')
logger.error(f'Error getting push subscriptions for user: {e}')
return []
def save_push_subscription(username, subscription_obj):
"""
Save a new push subscription for a user
Args:
username (str): Username
subscription_obj (dict): Subscription object from Service Worker
{
'endpoint': 'https://...',
'keys': {
'p256dh': '...',
'auth': '...'
}
}
Returns:
bool: Success status
Save a new push subscription for a user with field-level encryption.
"""
try:
if not subscription_obj.get('endpoint'):
logger.warning(f'Invalid subscription object for {username}')
endpoint = subscription_obj.get('endpoint')
if not endpoint:
logger.warning('Invalid subscription object: missing endpoint')
return False
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
subs_col = get_push_subscriptions_collection(db)
# Create unique hash of subscription to avoid duplicates
sub_hash = hashlib.md5(
f"{username}:{subscription_obj['endpoint']}".encode()
# Create unique hash of subscription using plaintext data to avoid duplicates
sub_hash = hashlib.shake_256(
f"{username}:{endpoint}".encode('utf-8')
).hexdigest()
# Check if subscription already exists
# Check if subscription already exists by Hash
existing = subs_col.find_one({
'Username': username,
'SubscriptionHash': sub_hash
})
if existing:
# Update last used time
subs_col.update_one(
{'_id': existing['_id']},
{'$set': {
@@ -136,15 +140,19 @@ def save_push_subscription(username, subscription_obj):
'IsActive': True
}}
)
logger.info(f'Updated existing subscription for {username}')
logger.info('Updated existing push subscription')
client.close()
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 = {
'Username': username,
'Endpoint': subscription_obj['endpoint'],
'Keys': subscription_obj.get('keys', {}),
'UsernameHash': _get_username_hash(username),
'Username': encrypt_text(username),
'Endpoint': encrypt_text(endpoint),
'Keys': encrypt_text(keys_str),
'SubscriptionHash': sub_hash,
'IsActive': True,
'CreatedAt': datetime.datetime.now(),
@@ -153,36 +161,31 @@ def save_push_subscription(username, subscription_obj):
}
subs_col.insert_one(subscription_doc)
logger.info(f'Saved new push subscription for {username}')
logger.info('Saved new encrypted push subscription')
client.close()
return True
except Exception as e:
logger.error(f'Error saving push subscription for {username}: {e}')
logger.error(f'Error saving push subscription: {e}')
return False
def remove_push_subscription(username, endpoint):
"""
Remove a push subscription
Args:
username (str): Username
endpoint (str): Subscription endpoint URL
Returns:
bool: Success status
Remove a push subscription by making it inactive.
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_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(
{
'Username': username,
'Endpoint': endpoint
},
{'SubscriptionHash': sub_hash},
{'$set': {'IsActive': False}}
)
@@ -190,31 +193,19 @@ def remove_push_subscription(username, endpoint):
return result.modified_count > 0
except Exception as e:
logger.error(f'Error removing push subscription for {username}: {e}')
logger.error(f'Error removing push subscription: {e}')
return False
def send_push_notification(username, title, body, icon=None, url='/', reference=None, tag='notification'):
"""
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
Send a push notification to all user's subscriptions.
"""
try:
subscriptions = get_user_subscriptions(username)
if not subscriptions:
logger.debug(f'No active push subscriptions for {username}')
logger.debug('No active push subscriptions for user')
return 0
sent_count = 0
@@ -232,19 +223,18 @@ def send_push_notification(username, title, body, icon=None, url='/', reference=
if success:
sent_count += 1
else:
# Mark subscription as inactive if send fails
_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
except Exception as e:
logger.error(f'Error sending push notification to {username}: {e}')
logger.error(f'Error sending push notification: {e}')
return 0
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:
payload = {
'title': title,
@@ -256,20 +246,17 @@ def _send_to_subscription(subscription, title, body, icon, url, reference, tag):
'reference': reference or {},
}
# If using Firebase Cloud Messaging
if FCM_API_KEY and subscription.get('Endpoint', '').startswith('https://fcm.'):
return _send_fcm_notification(subscription, payload)
# Otherwise use standard Web Push Protocol
return _send_web_push_notification(subscription, payload)
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
def _send_fcm_notification(subscription, payload):
"""Send notification via Firebase Cloud Messaging"""
try:
if not FCM_API_KEY:
logger.warning('FCM_API_KEY not configured')
@@ -311,9 +298,7 @@ def _send_fcm_notification(subscription, payload):
def _send_web_push_notification(subscription, payload):
"""Send notification using standard Web Push Protocol"""
try:
# This requires pywebpush library
from pywebpush import webpush
webpush(
@@ -325,13 +310,13 @@ def _send_web_push_notification(subscription, payload):
vapid_private_key=VAPID_PRIVATE_KEY,
vapid_claims={'sub': VAPID_SUBJECT},
timeout=10,
ttl=3600 # Notification expires after 1 hour if device is offline
ttl=3600
)
return True
except ImportError:
logger.warning('pywebpush not installed, install with: pip install pywebpush')
logger.warning('pywebpush not installed. pip install pywebpush')
return False
except Exception as 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):
"""Mark a subscription as inactive (e.g., after failed send)"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
@@ -349,37 +333,21 @@ def _mark_subscription_inactive(subscription_id):
{'_id': ObjectId(subscription_id)},
{'$set': {'IsActive': False}}
)
client.close()
except Exception as e:
logger.error(f'Error marking subscription inactive: {e}')
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:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
users_col = db['users']
# Get all admin users
admin_users = list(users_col.find(
{'Admin': True},
{'Username': 1}
))
client.close()
total_sent = 0
@@ -404,10 +372,6 @@ def send_push_to_all_admins(title, body, icon=None, url='/', reference=None):
def cleanup_inactive_subscriptions():
"""
Remove inactive subscriptions older than 30 days
Run this periodically as a maintenance task
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
@@ -429,22 +393,19 @@ def cleanup_inactive_subscriptions():
return 0
# Database collection schema
def ensure_push_subscriptions_collection():
"""Ensure the push_subscriptions collection exists with proper indexes"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
subs_col = get_push_subscriptions_collection(db)
# Create indexes
subs_col.create_index('Username')
subs_col.create_index([('Username', 1), ('IsActive', 1)])
subs_col.create_index([('CreatedAt', 1)]) # TTL-like usage
subs_col.create_index('UsernameHash')
subs_col.create_index([('UsernameHash', 1), ('IsActive', 1)])
subs_col.create_index([('CreatedAt', 1)])
subs_col.create_index('SubscriptionHash', unique=True)
logger.info('Push subscriptions collection indexes created')
client.close()
except Exception as e:
logger.error(f'Error ensuring push subscriptions collection: {e}')
logger.error(f'Error ensuring push subscriptions collection: {e}')
+47
View File
@@ -1144,6 +1144,17 @@
<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>
<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) %}
<li><a class="dropdown-item" href="{{ url_for('home') }}">Inventarsystem</a></li>
{% endif %}
@@ -1155,6 +1166,42 @@
</ul>
</li>
</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>
</nav>
+14 -1
View File
@@ -155,7 +155,7 @@
const slotLengthInput = document.getElementById('slot_length');
const clientsperslot = document.getElementById('clients_per_slot')
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) {
return;
@@ -442,6 +442,19 @@
if (startDateInput.value && endDateInput.value) {
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>
{% endblock %}
+46 -2
View File
@@ -6,6 +6,8 @@
<div class="container py-4">
<div class="row justify-content-center">
<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;">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-4 align-items-start align-items-lg-end">
<div>
@@ -20,6 +22,39 @@
</div>
</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="col-12 col-md-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>
</div>
<!-- "Tabelle" (Liste) der kommenden Termine -->
<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">
<h2 class="h5 fw-bold mb-0">Kommende Termine</h2>
@@ -84,10 +120,18 @@
<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="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 %}
<a class="btn btn-sm btn-outline-primary" href="{{ event.calendar_link }}">.ics</a>
{% 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?');">
<button type="submit" class="btn btn-sm btn-outline-danger">Entfernen</button>
</form>
@@ -105,4 +149,4 @@
</div>
</div>
</div>
{% endblock %}
{% endblock %}