Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d121f6110 | |||
| 9d940c6151 | |||
| 980b825e07 | |||
| 94326728eb | |||
| efe17883a6 | |||
| 76f93b3d2e | |||
| 8f793045c2 | |||
| a199439d76 | |||
| 6cec483390 | |||
| 10e2f7cc17 | |||
| 0f8c5a0fec | |||
| 091147ecfd | |||
| 7bedfc1558 | |||
| 7e258e07ab | |||
| 96245bb06b | |||
| 245c3c19dd | |||
| d76c69d836 | |||
| 9527fc10cf |
+168
-186
@@ -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", "borrower", "ip"]
|
||||
|
||||
# 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,
|
||||
}
|
||||
@@ -3590,7 +3604,6 @@ def api_item_detail(item_id):
|
||||
# Basic detail HTML
|
||||
detail_html = f"""
|
||||
<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>Beschreibung:</strong> {html.escape(item.get('Beschreibung', '-'))}</p>
|
||||
<p><strong>Status:</strong> {html.escape(status_label)}</p>
|
||||
@@ -3599,10 +3612,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 +4228,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 +4401,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 +4423,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 +4454,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 +4739,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 +4756,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 +4831,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 +4921,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 +4965,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 +5081,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 +5121,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
|
||||
@@ -5128,7 +5143,8 @@ def upload_item():
|
||||
individual_codes_raw = sanitize_form_value(request.form.get('individual_codes', ''))
|
||||
item_count_raw = sanitize_form_value(request.form.get('item_count', '1'))
|
||||
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={individual_codes_raw!r}")
|
||||
try:
|
||||
item_count = int(item_count_raw) if item_count_raw else 1
|
||||
except (TypeError, ValueError):
|
||||
@@ -5140,6 +5156,8 @@ def upload_item():
|
||||
if individual_codes_raw:
|
||||
individual_codes = [c.strip() for c in str(individual_codes_raw).replace(',', '\n').splitlines() if c.strip()]
|
||||
|
||||
app.logger.info(f"DEBUG: Parsed individual codes: {individual_codes}, count: {len(individual_codes)}")
|
||||
|
||||
# Check if this is a duplication
|
||||
is_duplicating = request.form.get('is_duplicating') == 'true'
|
||||
|
||||
@@ -5175,7 +5193,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
|
||||
@@ -5255,6 +5273,8 @@ def upload_item():
|
||||
for c in extra_codes:
|
||||
if c not in all_item_codes:
|
||||
all_item_codes.append(c)
|
||||
|
||||
app.logger.info(f"DEBUG: Unified item codes: {all_item_codes}, total count: {len(all_item_codes)}")
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 2. OVERRIDE ITEM COUNT
|
||||
@@ -5339,7 +5359,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 +5558,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 +5666,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 +5725,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 +5775,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 +5786,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 +5922,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 +5958,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
|
||||
@@ -5995,17 +6013,6 @@ def upload_item():
|
||||
|
||||
for position in range(1, item_count + 1):
|
||||
unique_code = None
|
||||
if position <= len(individual_codes):
|
||||
unique_code = individual_codes[position - 1]
|
||||
else:
|
||||
unique_code = generate_unique_batch_code(base_code, position)
|
||||
|
||||
if (base_code or individual_codes) and not unique_code:
|
||||
error_msg = 'Fehler bei der Code-Erzeugung für mehrere Artikel.'
|
||||
if is_mobile:
|
||||
return jsonify({'success': False, 'message': error_msg}), 400
|
||||
flash(error_msg, 'error')
|
||||
return redirect(url_for(success_redirect_endpoint))
|
||||
|
||||
parent_item_id = str(created_item_ids[0]) if created_item_ids else None
|
||||
item_id = it.add_item(
|
||||
@@ -6023,6 +6030,7 @@ def upload_item():
|
||||
isbn=item_isbn,
|
||||
item_type=item_type
|
||||
)
|
||||
app.logger.info(f"Created item {position}/{item_count}: ID={item_id}, parent={parent_item_id}, series_group={series_group_id}")
|
||||
if not item_id:
|
||||
break
|
||||
created_item_ids.append(item_id)
|
||||
@@ -6093,7 +6101,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 +6182,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 +6335,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 +6467,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 +7198,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 +7297,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 +7323,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 +7404,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 +7502,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 +7576,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 +7679,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 +7730,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 +7916,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 +8024,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 +8043,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 +8095,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 +8114,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 +8122,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 +8185,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 +8234,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 +8303,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 +8918,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 +9422,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 +9722,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 +9748,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 +9767,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 +9776,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 +9787,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 +9806,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 +9871,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 +9968,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 +10173,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 +10252,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 +10720,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 +10792,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 +10827,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 +10908,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 +10947,21 @@ def reset_item(id):
|
||||
if result['success']:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': result['message'],
|
||||
'details': result.get('details', {})
|
||||
'message': 'Item reset successfully'
|
||||
})
|
||||
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 +11107,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 +11147,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 +11378,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 +11407,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 +11545,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 +11643,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 +11688,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 +11830,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 +11840,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 +11867,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 +11877,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 +11911,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 +11936,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 +11975,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
|
||||
|
||||
@@ -1235,5 +1235,5 @@ def reset_item_completely(item_id):
|
||||
except Exception as e:
|
||||
return {
|
||||
'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
|
||||
|
||||
|
||||
LIBRARY_ITEM_TYPES = ('book', 'cd', 'dvd', 'media')
|
||||
LIBRARY_ITEM_TYPES = ('book', 'cd', 'dvd', 'media', 'schulbuch')
|
||||
|
||||
|
||||
def _non_library_query(extra_query=None):
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -215,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):
|
||||
@@ -239,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')
|
||||
)
|
||||
|
||||
|
||||
@@ -356,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(
|
||||
@@ -366,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')
|
||||
)
|
||||
|
||||
|
||||
@@ -553,4 +557,5 @@ def main():
|
||||
current_user=current_user,
|
||||
upcoming_events=upcoming_events,
|
||||
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
|
||||
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}')
|
||||
@@ -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>
|
||||
|
||||
@@ -1057,7 +1057,7 @@
|
||||
document.getElementById('detailModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// =========================================================================
|
||||
// 5. EVENT LISTENERS INITIALIZATION & ON-LOAD INITIALIZER
|
||||
// =========================================================================
|
||||
function wireScannerUi() {
|
||||
|
||||
Reference in New Issue
Block a user