Compare commits

..

23 Commits

Author SHA1 Message Date
Aiirondev_dev 46f79b002b Changes to the admin_damaged_items 2026-07-31 21:40:20 +02:00
Aiirondev_dev dcc8aae650 Implemenmtation of the new encryption into the my borrowed funktion 2026-07-31 21:28:34 +02:00
Aiirondev_dev c9fdf41e0b Implemenmtation of the new encryption into the my borrowed funktion 2026-07-31 21:21:43 +02:00
Aiirondev_dev 3e26c691b1 Addition of backwarts compatibility 2026-07-31 20:57:19 +02:00
Aiirondev_dev 6c3d62e84b Fix of a NoneType has no attribute 'get' Error 2026-07-31 20:50:58 +02:00
Aiirondev_dev 88f6c3c0f5 backwarts compaibility and integration of encryption in the manage-tenant.sh 2026-07-31 20:45:16 +02:00
Aiirondev_dev 08d8b78d91 changes to the encryption to the users and Items 2026-07-31 20:16:34 +02:00
Aiirondev_dev 258e287a39 changes to the encryption to the users and Items 2026-07-31 19:55:59 +02:00
Aiirondev_dev 9b12d9a3d0 changes to the encryption to the users and Items 2026-07-31 15:15:09 +02:00
Aiirondev_dev 237788af96 implementation of encryption for the username to avoid any recognission potetioal 2026-07-31 13:12:46 +02:00
Aiirondev_dev 7368d82fc4 changes to fully incorpereate the school info managment back into the working system 2026-07-30 23:55:46 +02:00
Aiirondev_dev 3e5e243ddf changes to the autorisation system for the page autorisation to have a continued line of page authentification and no discrepencies in the Software it self 2026-07-30 23:34:06 +02:00
Aiirondev_dev 8176cea8fe change of the HTML Signature 2026-07-30 16:52:34 +02:00
Aiirondev_dev b71f2e9089 changes 2026-07-30 01:09:58 +02:00
Aiirondev_dev 1331afa4da Fix of hexdigest 2026-07-29 13:40:17 +02:00
Aiirondev_dev 5a2d703bc7 Debug of Vapid Keys 2026-07-29 13:35:15 +02:00
Aiirondev_dev 8e6dd17243 Fix of the notification subscription 2026-07-29 11:57:34 +02:00
Aiirondev_dev 2124ca65b2 Fix of the notification subscription 2026-07-29 11:49:54 +02:00
Aiirondev_dev bc86dc4a0d Fix of the notification subscription 2026-07-29 11:43:18 +02:00
Aiirondev_dev a49ed98ed7 Fix of the notification subscription 2026-07-29 11:35:04 +02:00
Aiirondev_dev 4d9bb62907 Fix of the notification subscription 2026-07-29 11:15:57 +02:00
Aiirondev_dev 8251cd9bfd Fix of a dubled function 2026-07-29 00:22:35 +02:00
Aiirondev_dev 3ed3148b8a Fix of the notifikationssystem 2026-07-29 00:10:12 +02:00
12 changed files with 947 additions and 1385 deletions
+184 -207
View File
@@ -10248,74 +10248,58 @@ def get_period_times(booking_date, period_num):
"""---------------------------------------------------------Borrowing-----------------------------------------------------------------"""
@app.route('/my_borrowed_items')
def my_borrowed_items():
"""
Zeigt alle vom aktuellen Benutzer ausgeliehenen und geplanten Objekte an.
Returns:
Response: Gerendertes Template mit den ausgeliehenen und geplanten Objekten des Benutzers
"""
if 'username' not in session:
flash('Bitte melden Sie sich an, um Ihre ausgeliehenen Objekte anzuzeigen', 'error')
return redirect(url_for('login', next=request.path))
username = session['username']
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
items_collection = db.items
ausleihungen_collection = db.ausleihungen
# Get current time for comparison
current_time = datetime.datetime.now()
# Check if user is admin
user_is_admin = False
if 'is_admin' in session:
user_is_admin = session['is_admin']
# Get items currently borrowed by the user (where Verfuegbar=false and User=username)
borrowed_items = list(items_collection.find({'Verfuegbar': False, 'User': username}))
# Get active and planned ausleihungen for the user
active_ausleihungen = list(ausleihungen_collection.find({
'User': username,
'Status': 'active'
items_collection = db['items']
ausleihungen_collection = db['ausleihungen']
all_ausleihungen = list(ausleihungen_collection.find({
'Status': {'$in': ['active', 'planned']}
}))
planned_ausleihungen = list(ausleihungen_collection.find({
'User': username,
'Status': 'planned'
}))
# Process items
active_items = []
planned_items = []
processed_item_ids = set() # Keep track of processed item IDs to avoid duplicates
# First, process items that are directly marked as borrowed by the user
for item in borrowed_items:
# Convert ObjectId to string for template
item['_id'] = str(item['_id'])
active_items.append(item)
processed_item_ids.add(item['_id'])
# Process active appointments
for appointment in active_ausleihungen:
# Get the item ID from the appointment
processed_item_ids = set()
for appointment in all_ausleihungen:
raw_user = appointment.get('User', '')
try:
decrypted_user = decrypt_text(raw_user) if raw_user else ''
except Exception as e:
app.logger.error(f"Entschlüsselungsfehler: {e}")
decrypted_user = ''
if decrypted_user != username:
continue
item_id = appointment.get('Item')
if not item_id or str(item_id) in processed_item_ids:
continue # Skip if we already processed this item or no item ID
# Get item details
item_obj = items_collection.find_one({'_id': ObjectId(item_id)})
if not item_id:
continue
try:
if isinstance(item_id, str):
query_id = ObjectId(item_id)
else:
query_id = item_id
item_obj = items_collection.find_one({'_id': query_id})
except Exception:
item_obj = None
if item_obj:
# Convert ObjectId to string for template
item_obj['_id'] = str(item_obj['_id'])
# Add appointment data
item_obj['AppointmentData'] = {
'id': str(appointment['_id']),
'start': appointment.get('Start'),
@@ -10324,62 +10308,63 @@ def my_borrowed_items():
'period': appointment.get('Period'),
'status': appointment.get('VerifiedStatus', appointment.get('Status')),
}
# Mark that this item is part of an active appointment
item_obj['ActiveAppointment'] = True
# Add to the list only if not already there
if str(item_obj['_id']) not in processed_item_ids:
active_items.append(item_obj)
processed_item_ids.add(str(item_obj['_id']))
# Process planned appointments
for appointment in planned_ausleihungen:
item_id = appointment.get('Item')
if not item_id:
continue
item_obj = items_collection.find_one({'_id': ObjectId(item_id)})
if item_obj:
item_obj['_id'] = str(item_obj['_id'])
# Add appointment data
item_obj['AppointmentData'] = {
'id': str(appointment['_id']),
'start': appointment.get('Start'),
'end': appointment.get('End'),
'notes': appointment.get('Notes'),
'period': appointment.get('Period'),
'status': appointment.get('Status'),
}
planned_items.append(item_obj)
status = appointment.get('Status')
if status == 'active':
item_obj['ActiveAppointment'] = True
if str(item_obj['_id']) not in processed_item_ids:
active_items.append(item_obj)
processed_item_ids.add(str(item_obj['_id']))
elif status == 'planned':
planned_items.append(item_obj)
all_borrowed_items = list(items_collection.find({'Verfuegbar': False}))
for item in all_borrowed_items:
raw_item_user = item.get('User', '')
try:
dec_item_user = decrypt_text(raw_item_user) if raw_item_user else ''
except Exception:
dec_item_user = ''
if dec_item_user == username and str(item['_id']) not in processed_item_ids:
item['_id'] = str(item['_id'])
item['ActiveAppointment'] = True
item['AppointmentData'] = {'status': 'active (no document)'}
active_items.append(item)
processed_item_ids.add(item['_id'])
client.close()
# DEBUG: Log what we're passing to the template
app.logger.info(f"Passing {len(active_items)} active items and {len(planned_items)} planned items to template")
if planned_items:
for i, item in enumerate(planned_items):
app.logger.info(f"Planned item {i+1}: {item['Name']}, Appointment ID: {item['AppointmentData']['id']}")
# DEBUG Logging
app.logger.info(
f"Passing {len(active_items)} active items and {len(planned_items)} planned items to template for user {username}")
return render_template(
'my_borrowed_items.html',
items=active_items,
planned_items=planned_items,
user_is_admin=user_is_admin
planned_items=planned_items
)
@app.route('/api/push/vapid-key', methods=['GET'])
def get_vapid_key():
"""Returns the VAPID public key for web push subscriptions."""
"""
Returns the VAPID public key for web push subscriptions
"""
from Web.push_notifications import _get_vapid_public
if not _get_vapid_public():
return jsonify({'error': 'VAPID public key not configured'}), 500
return jsonify({'publicKey': _get_vapid_public()})
if 'username' not in session:
return jsonify({'success': False, 'error': 'Not authenticated'}), 401
try:
if not _get_vapid_public():
return jsonify({'success': False, 'error': 'VAPID public key not configured'}), 500
return jsonify({
'success': True,
'publicKey': _get_vapid_public()
})
except Exception as e:
app.logger.error(f'Error getting VAPID key: {e}')
return jsonify({'success': False}), 500
@app.route('/notifications')
def notifications_view():
@@ -10404,15 +10389,17 @@ def notifications_view():
admin_notifications = []
for notif in notifications:
is_read = username in (notif.get('ReadBy') or [])
created_at_val = notif.get('CreatedAt')
row = {
'id': str(notif.get('_id')),
'title': notif.get('Title', 'Benachrichtigung'),
'message': notif.get('Message', ''),
'severity': notif.get('Severity', 'info'),
'type': notif.get('Type', ''),
'created_at': notif.get('CreatedAt'),
'created_at': created_at_val if created_at_val else None,
'is_read': is_read,
'reference': notif.get('Reference', {}) or {},
'reference': notif.get('Reference') or {},
}
if notif.get('Audience') == 'admin':
admin_notifications.append(row)
@@ -10466,45 +10453,49 @@ def mark_notification_read(notification_id):
return redirect(url_for('notifications_view'))
@app.route('/notifications/mark_all_read', methods=['POST'])
@app.route('/notifications/mark-all-read', methods=['POST'])
def mark_all_notifications_read():
"""Mark all visible notifications as read for the current user."""
if 'username' not in session:
flash('Bitte melden Sie sich an.', 'error')
return redirect(url_for('login'))
username = session['username']
is_admin_user = False
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['actions'].get('can_manage_settings', False):
is_admin_user = True
else:
is_admin_user = False
query = {
'$or': [
{'Audience': 'user', 'TargetUser': username},
]
}
if is_admin_user:
query['$or'].append({'Audience': 'admin'})
current_permissions = us.get_effective_permissions(username)
is_admin_user = current_permissions['actions'].get('can_manage_settings', False)
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
result = db['notifications'].update_many(
# Filter-Logik: Welche Benachrichtigungen sollen als gelesen markiert werden?
# Wenn Sie 'Audience' nutzen (wie in Ihrer notifications_view Route):
query = {}
if is_admin_user:
# Ein Admin sieht sowohl an ihn gerichtete als auch allgemeine Admin-Meldungen
query['$or'] = [
{'TargetUser': username},
{'Audience': 'admin'}
]
else:
# Normale User sehen nur Meldungen, die an sie oder alle User gerichtet sind
query['$or'] = [
{'TargetUser': username},
{'Audience': 'user'}
]
# WICHTIG: Fügt den Benutzernamen per $addToSet in das ReadBy-Array ein.
# $addToSet verhindert, dass der Name doppelt eingetragen wird, falls er schon drinsteht.
db['notifications'].update_many(
query,
{
'$addToSet': {'ReadBy': username},
'$set': {'UpdatedAt': datetime.datetime.now()}
}
{'$addToSet': {'ReadBy': username}}
)
if result.modified_count > 0:
_bump_notification_version(f'user:{username}')
flash('Alle Benachrichtigungen wurden als gelesen markiert.', 'success')
except Exception as exc:
app.logger.warning(f"Could not mark all notifications as read for {encrypt_text(username)}: {exc}")
app.logger.error(f"Error marking all notifications as read: {exc}")
flash('Fehler beim Aktualisieren der Benachrichtigungen.', 'error')
finally:
if client:
client.close()
@@ -10593,15 +10584,24 @@ def notifications_unread_status():
client.close()
@app.route('/admin/damaged_items')
@app.route('/admin/damaged_items')
def admin_damaged_items():
"""Admin-Übersicht aller aktiven und vergangenen Ausleihen."""
"""Admin-Übersicht aller Ausleihen von beschädigten Objekten."""
if 'username' not in session:
flash('Administratorrechte erforderlich.', 'error')
flash('Anmeldung erforderlich.', 'error')
return redirect(url_for('login'))
# SICHERHEIT: Berechtigungsprüfung (wie in admin_borrowings)
# Entferne die Kommentare, falls du `us` in dieser Datei importiert hast
"""
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['pages'].get('admin_damaged_items', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
return redirect(url_for('home_admin'))
"""
# Import sicherstellen
from modules.inventarsystem.data_protection import decrypt_text
from bson.objectid import ObjectId
client = None
try:
@@ -10610,36 +10610,53 @@ def admin_damaged_items():
ausleihungen_col = db['ausleihungen']
items_col = db['items']
ausleihungen = list(ausleihungen_col.find().sort('Start', -1))
alle_ausleihungen = list(ausleihungen_col.find().sort('Start', -1))
for record in ausleihungen:
raw_user = record.get('User', '')
if raw_user:
record['User'] = decrypt_text(raw_user)
beschädigte_ausleihungen = []
for record in alle_ausleihungen:
item_id = record.get('Item')
if item_id:
try:
item_doc = items_col.find_one({'_id': ObjectId(item_id)})
if item_doc:
if not item_id:
continue
try:
if isinstance(item_id, str):
query_id = ObjectId(item_id)
else:
query_id = item_id
item_doc = items_col.find_one({'_id': query_id})
if item_doc:
condition_value = str(item_doc.get('Condition', '')).strip().lower()
has_damage = bool(item_doc.get('HasDamage')) or condition_value == 'destroyed' or bool(
item_doc.get('DamageReports'))
if has_damage:
raw_user = record.get('User', '')
if raw_user:
record['User'] = decrypt_text(raw_user)
if item_doc.get('User'):
item_doc['User'] = decrypt_text(item_doc['User'])
record['ItemDetails'] = item_doc
except Exception as e:
app.logger.warning(f"Konnte Item {item_id} für Ausleihe {record.get('_id')} nicht laden: {e}")
beschädigte_ausleihungen.append(record)
except Exception as e:
app.logger.warning(f"Konnte Item {item_id} für Ausleihe {record.get('_id')} nicht laden: {e}")
return render_template(
'admin_damaged_items.html',
ausleihungen=ausleihungen,
'admin_damaged_items.html',
ausleihungen=beschädigte_ausleihungen,
library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
mail_module_enabled=cfg.MODULES.is_enabled('mail')
)
except Exception as exc:
app.logger.error(f"Fehler beim Laden der Ausleihen-Verwaltung: {exc}")
flash('Fehler beim Laden der Ausleihen-Übersicht.', 'error')
app.logger.error(f"Fehler beim Laden der beschädigten Objekte: {exc}")
flash('Fehler beim Laden der Übersicht.', 'error')
return redirect(url_for('home_admin'))
finally:
if client:
@@ -12115,44 +12132,33 @@ def get_optimal_image_quality(img, target_size_kb=80):
def health_check():
return 'OK', 200
@app.route('/api/push/subscribe', methods=['POST'])
def subscribe_to_push():
"""
Subscribe a user to push notifications
Expects JSON payload:
{
'subscription': {
'endpoint': '...',
'keys': {'p256dh': '...', 'auth': '...'}
}
}
"""
if 'username' not in session:
return jsonify({'success': False, 'error': 'Not authenticated'}), 401
return jsonify({'success': False, 'error': 'Unauthorized'}), 401
data = request.get_json(silent=True) or {}
subscription_obj = data.get('subscription') if 'subscription' in data else data
if not subscription_obj or not isinstance(subscription_obj, dict):
app.logger.error("Invalid subscription payload received")
return jsonify({'success': False, 'error': 'Invalid payload'}), 400
username = session['username']
try:
data = request.get_json() or {}
subscription = data.get('subscription')
if not subscription or not subscription.get('endpoint'):
return jsonify({'success': False, 'error': 'Invalid subscription'}), 400
username = session['username']
success = pn.save_push_subscription(username, subscription)
success = pn.save_push_subscription(username, subscription_obj)
if success:
app.logger.info(f'Push subscription saved for {encrypt_text(username)}')
return jsonify({
'success': True,
'message': 'Successfully subscribed to push notifications'
})
return jsonify({'success': True})
else:
return jsonify({'success': False, 'error': 'Failed to save subscription'}), 500
return jsonify({'success': False, 'error': 'Failed to save in DB'}), 400
except Exception as e:
app.logger.error(f'Error subscribing to push: {e}')
return jsonify({'success': False}), 500
app.logger.error(f"Error in subscribe_to_push route: {e}")
return jsonify({'success': False, 'error': 'Internal server error'}), 500
@app.route('/api/push/unsubscribe', methods=['POST'])
@@ -12169,7 +12175,7 @@ def unsubscribe_from_push():
return jsonify({'success': False, 'error': 'Not authenticated'}), 401
try:
data = request.get_json() or {}
data = request.get_json(silent=True) or {}
endpoint = data.get('endpoint')
if not endpoint:
@@ -12225,32 +12231,6 @@ def get_push_subscriptions():
app.logger.error(f'Error getting push subscriptions: {e}')
return jsonify({'success': False}), 500
@app.route('/api/push/vapid-key', methods=['GET'])
def get_vapid_key():
"""
Get the VAPID public key for push notifications
Used by the service worker to communicate with push service
"""
try:
vapid_key = pn.VAPID_PUBLIC_KEY
if not vapid_key:
app.logger.warning('VAPID_PUBLIC_KEY not configured')
return jsonify({
'success': False,
'error': 'Push notifications not configured on server'
}), 501
return jsonify({
'success': True,
'vapid_key': vapid_key
})
except Exception as e:
app.logger.error(f'Error getting VAPID key: {e}')
return jsonify({'success': False}), 500
@app.route('/api/push/test', methods=['POST'])
def test_push_notification():
"""
@@ -12259,11 +12239,8 @@ def test_push_notification():
if 'username' not in session:
return jsonify({'success': False, 'error': 'Not authenticated'}), 401
if not us.is_admin(session['username']):
return jsonify({'success': False, 'error': 'Admin access required'}), 403
try:
data = request.get_json() or {}
data = request.get_json(silent=True) or {}
target_user = data.get('target_user', session['username'])
sent = pn.send_push_notification(
File diff suppressed because it is too large Load Diff
+205 -173
View File
@@ -19,11 +19,61 @@ Collection Structure:
- Status fields: Verfuegbar, User (if currently borrowed)
"""
from bson.objectid import ObjectId
from bson.errors import InvalidId
import datetime
import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient
import Web.modules.inventarsystem.data_protection as dp
def safe_decrypt_user(encrypted_user):
"""
Safely decrypt an encrypted username string.
Returns the original string if decryption fails or if input is empty/None.
"""
if not encrypted_user:
return encrypted_user
try:
return dp.decrypt_text(encrypted_user)
except Exception as e:
print(f"Error decrypting user data: {e}")
# Return fallback value or None to prevent downstream crashes
return "[Decryption Failed]"
def decrypt_item_user_data(item):
"""
Decrypts encrypted user fields within an inventory item document in-place.
Args:
item (dict): MongoDB document representing an item.
Returns:
dict: The item with decrypted user fields.
"""
if not item:
return item
# 1. Decrypt top-level 'User' field if present
if 'User' in item and item['User']:
item['User'] = safe_decrypt_user(item['User'])
# 2. Decrypt nested 'user' field in 'NextAppointment' if present
if 'NextAppointment' in item and isinstance(item['NextAppointment'], dict):
if 'user' in item['NextAppointment']:
item['NextAppointment']['user'] = safe_decrypt_user(item['NextAppointment']['user'])
return item
def _to_object_id(id_str):
"""Safely convert a string to ObjectId."""
try:
return ObjectId(id_str)
except (InvalidId, TypeError):
return None
LIBRARY_ITEM_TYPES = ('book', 'cd', 'dvd', 'other', 'schoolbook', 'Buch', 'Schulbuch', 'schulbuch')
@@ -52,7 +102,7 @@ def add_item(name, ort, beschreibung, images=None, filter=None, filter2=None, fi
isbn=None, item_type='general', library_category=None, is_library=False):
"""
Add a new item to the inventory.
Args:
name (str): Name of the item
ort (str): Location of the item
@@ -73,7 +123,7 @@ def add_item(name, ort, beschreibung, images=None, filter=None, filter2=None, fi
isbn (str, optional): ISBN for books or media items
item_type (str, optional): Type of the item (e.g., 'general', 'book', 'cd')
library_category (str, optional): Library category for the item
Returns:
ObjectId: ID of the new item or None if failed
"""
@@ -102,7 +152,7 @@ def add_item(name, ort, beschreibung, images=None, filter=None, filter2=None, fi
'ISBN': isbn,
'library_category': library_category,
'ItemType': item_type,
'is_library': is_library,
'is_library': is_library,
'SeriesGroupId': series_group_id,
'SeriesCount': series_count,
'SeriesPosition': series_position,
@@ -124,10 +174,10 @@ def add_item(name, ort, beschreibung, images=None, filter=None, filter2=None, fi
def remove_item(id):
"""
Soft-delete an item from the inventory.
Args:
id (str): ID of the item to remove
Returns:
bool: True if successful, False otherwise
"""
@@ -199,21 +249,19 @@ def get_group_item_ids(id):
return []
def update_item(id, name, ort, beschreibung, images, verfuegbar, filter1, filter2, filter3,
def update_item(id, name, ort, beschreibung, images, verfuegbar, filter1, filter2, filter3,
ansch_jahr, ansch_kost, code_4, reservierbar, isbn=None, item_type='general'):
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
# 1. Altes Item laden, um SeriesGroupId zu bestimmen
old_item = items.find_one({'_id': ObjectId(id)})
if not old_item:
return False
series_group_id = old_item.get('SeriesGroupId')
# 2. Shared Data: Daten, die für ALLE in der Gruppe gleich sind
shared_update = {
'Name': name,
'Ort': ort,
@@ -227,18 +275,15 @@ def update_item(id, name, ort, beschreibung, images, verfuegbar, filter1, filter
'Reservierbar': reservierbar,
'ISBN': isbn,
'ItemType': item_type,
'Verfuegbar': verfuegbar, # Wir behalten den Status bei
'Verfuegbar': verfuegbar,
'LastUpdated': datetime.datetime.now()
}
# 3. Spezifische Daten: Was NICHT synchronisiert wird
specific_update = shared_update.copy()
specific_update['Code_4'] = code_4
# 4. Das aktuelle Item updaten
items.update_one({'_id': ObjectId(id)}, {'$set': specific_update})
# 5. Alle anderen Gruppen-Mitglieder synchronisieren
if series_group_id:
items.update_many(
{
@@ -257,12 +302,12 @@ def update_item(id, name, ort, beschreibung, images, verfuegbar, filter1, filter
def update_item_status(id, verfuegbar, user=None):
"""
Update the availability status of an inventory item.
Args:
id (str): ID of the item to update
verfuegbar (bool): New availability status
user (str, optional): Username of person who borrowed the item
Returns:
bool: True if successful, False otherwise
"""
@@ -279,7 +324,7 @@ def update_item_status(id, verfuegbar, user=None):
update_query = {'$set': update_data}
if user is not None:
update_data['User'] = user
update_data['User'] = dp.encrypt_text(user)
elif verfuegbar:
# If item is being marked as available, clear the user field
update_query['$unset'] = {'User': ""}
@@ -299,11 +344,11 @@ def update_item_status(id, verfuegbar, user=None):
def update_item_exemplare_status(id, exemplare_status):
"""
Update the exemplar status of an inventory item.
Args:
id (str): ID of the item to update
exemplare_status (list): List of status objects for each exemplar
Returns:
bool: True if successful, False otherwise
"""
@@ -332,32 +377,32 @@ def update_item_exemplare_status(id, exemplare_status):
def is_code_unique(code_4, exclude_id=None):
"""
Check if a given code is unique (not used by any other item).
Args:
code_4 (str): The code to check
exclude_id (str, optional): ID of item to exclude from the check (for edit operations)
Returns:
bool: True if code is unique, False if already in use
"""
if not code_4 or code_4.strip() == "":
# Empty codes are not considered unique
return False
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
# Build query to find items with this code
query = {'Code_4': code_4, 'Deleted': {'$ne': True}}
# If we're editing an item, exclude it from the uniqueness check
if exclude_id:
query['_id'] = {'$ne': ObjectId(exclude_id)}
# Check if any items with this code exist
count = items.count_documents(query)
client.close()
return count == 0
@@ -367,7 +412,7 @@ def is_code_unique(code_4, exclude_id=None):
def get_items():
"""
Retrieve all inventory items.
Returns:
list: List of all inventory item documents with string IDs
"""
@@ -390,7 +435,7 @@ def get_items():
def get_available_items():
"""
Retrieve all available inventory items.
Returns:
list: List of available inventory item documents with string IDs
"""
@@ -413,7 +458,7 @@ def get_available_items():
def get_borrowed_items():
"""
Retrieve all currently borrowed inventory items.
Returns:
list: List of borrowed inventory item documents with string IDs
"""
@@ -432,36 +477,36 @@ def get_borrowed_items():
print(f"Error retrieving borrowed items: {e}")
return []
def get_item(id, decrypt=True):
"""
Retrieve an inventory item by ID, with optional decryption.
"""
item_id = _to_object_id(id)
if not item_id:
return None
def get_item(id):
"""
Retrieve a specific inventory item by its ID.
Args:
id (str): ID of the item to retrieve
Returns:
dict: The inventory item document or None if not found
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
item = items.find_one(_active_record_query({'_id': ObjectId(id)}))
client.close()
query = _active_record_query({'_id': item_id})
item = items.find_one(query)
if item:
item['_id'] = str(item['_id'])
if decrypt:
decrypt_item_user_data(item)
return item
except Exception as e:
print(f"Error retrieving item: {e}")
print(f"Error retrieving item {id}: {e}")
return None
def get_item_by_name(name):
"""
Retrieve a specific inventory item by its name.
Args:
name (str): Name of the item to retrieve
Returns:
dict: The inventory item document or None if not found
"""
@@ -480,10 +525,10 @@ def get_item_by_name(name):
def get_items_by_filter(filter_value):
"""
Retrieve inventory items matching a specific filter/category.
Args:
filter_value (str): Filter value to search for
Returns:
list: List of items matching the filter in primary, secondary, or tertiary category
"""
@@ -491,7 +536,7 @@ def get_items_by_filter(filter_value):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
# Use $or to find matches in any filter field
query = _active_record_query(_non_library_query({
'$or': [
@@ -500,14 +545,14 @@ def get_items_by_filter(filter_value):
{'Filter3': filter_value}
]
}))
results = list(items.find(query))
client.close()
# Convert ObjectId to string
for item in results:
item['_id'] = str(item['_id'])
return results
except Exception as e:
print(f"Error retrieving items by filter: {e}")
@@ -517,7 +562,7 @@ def get_items_by_filter(filter_value):
def get_filters():
"""
Retrieve all unique filter/category values from the inventory.
Returns:
list: Combined list of all primary, secondary and tertiary filter values
"""
@@ -529,16 +574,16 @@ def get_filters():
filters = items.distinct('Filter', non_library)
filters2 = items.distinct('Filter2', non_library)
filters3 = items.distinct('Filter3', non_library)
# Combine filters and remove None/empty values
all_filters = [f for f in filters + filters2 + filters3 if f]
# Remove duplicates while preserving order
unique_filters = []
for f in all_filters:
if f not in unique_filters:
unique_filters.append(f)
client.close()
return unique_filters
except Exception as e:
@@ -549,7 +594,7 @@ def get_filters():
def get_primary_filters():
"""
Retrieve all unique primary filter values.
Returns:
list: List of all primary filter values
"""
@@ -559,7 +604,7 @@ def get_primary_filters():
items = db['items']
filters = [f for f in items.distinct('Filter', _active_record_query(_non_library_query())) if f]
client.close()
# Add predefined values
predefined = get_predefined_filter_values(1)
return sorted(list(set(filters + predefined)))
@@ -571,7 +616,7 @@ def get_primary_filters():
def get_secondary_filters():
"""
Retrieve all unique secondary filter values.
Returns:
list: List of all secondary filter values
"""
@@ -581,7 +626,7 @@ def get_secondary_filters():
items = db['items']
filters = [f for f in items.distinct('Filter2', _active_record_query(_non_library_query())) if f]
client.close()
# Add predefined values
predefined = get_predefined_filter_values(2)
return sorted(list(set(filters + predefined)))
@@ -593,7 +638,7 @@ def get_secondary_filters():
def get_tertiary_filters():
"""
Retrieve all unique tertiary filter values.
Returns:
list: List of all tertiary filter values
"""
@@ -603,7 +648,7 @@ def get_tertiary_filters():
items = db['items']
filters = [f for f in items.distinct('Filter3', _active_record_query(_non_library_query())) if f]
client.close()
# Add predefined values
predefined = get_predefined_filter_values(3)
return sorted(list(set(filters + predefined)))
@@ -615,10 +660,10 @@ def get_tertiary_filters():
def get_item_by_code_4(code_4):
"""
Retrieve inventory items matching a specific 4-digit code.
Args:
code_4 (str): 4-digit code to search for
Returns:
list: List of items matching the code
"""
@@ -627,11 +672,11 @@ def get_item_by_code_4(code_4):
db = client[cfg.MONGODB_DB]
items = db['items']
results = list(items.find(_active_record_query(_non_library_query({"Code_4": code_4}))))
# Convert ObjectId to string
for item in results:
item['_id'] = str(item['_id'])
client.close()
return results
except Exception as e:
@@ -645,10 +690,10 @@ def unstuck_item(id):
"""
Remove all borrowing records for a specific item to reset its status.
Used to fix problematic or stuck items.
Args:
id (str): ID of the item to unstick
Returns:
bool: True if successful, False otherwise
"""
@@ -664,7 +709,7 @@ def unstuck_item(id):
'LastUpdated': datetime.datetime.now()
}}
)
# Also reset the item status
items = db['items']
items.update_one(
@@ -677,7 +722,7 @@ def unstuck_item(id):
'$unset': {'User': ""}
}
)
client.close()
return True
except Exception as e:
@@ -688,24 +733,24 @@ def unstuck_item(id):
def get_predefined_filter_values(filter_num):
"""
Get predefined values for a specific filter.
Args:
filter_num (int): Filter number (1 for Unterrichtsfach, 2 for Jahrgangsstufe)
Returns:
list: List of predefined filter values
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
# Use a dedicated collection for filter presets
filter_presets = db['filter_presets']
# Find the document for the specified filter
filter_doc = filter_presets.find_one({'filter_num': filter_num})
client.close()
if filter_doc and 'values' in filter_doc:
# Sort values alphabetically
return sorted(filter_doc['values'])
@@ -725,60 +770,60 @@ def get_predefined_filter_values(filter_num):
def add_predefined_filter_value(filter_num, value):
"""
Add a new predefined value to a filter.
Args:
filter_num (int): Filter number (1 for Unterrichtsfach, 2 for Jahrgangsstufe)
value (str): Value to add
Returns:
bool: True if value was added, False if it already existed
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
filter_presets = db['filter_presets']
# Check if value already exists
filter_doc = filter_presets.find_one({
'filter_num': filter_num,
'values': value
})
if filter_doc:
# Value already exists
client.close()
return False
# Add the value to the filter
result = filter_presets.update_one(
{'filter_num': filter_num},
{'$push': {'values': value}},
upsert=True
)
client.close()
return result.modified_count > 0 or result.upserted_id is not None
def remove_predefined_filter_value(filter_num, value):
"""
Remove a predefined value from a filter.
Args:
filter_num (int): Filter number (1 for Unterrichtsfach, 2 for Jahrgangsstufe)
value (str): Value to remove
Returns:
bool: True if value was removed, False otherwise
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
filter_presets = db['filter_presets']
# Remove the value from the filter
result = filter_presets.update_one(
{'filter_num': filter_num},
{'$pull': {'values': value}}
)
client.close()
return result.modified_count > 0
@@ -786,45 +831,45 @@ def remove_predefined_filter_value(filter_num, value):
def edit_predefined_filter_value(filter_num, old_value, new_value):
"""
Edit a predefined value from a filter and update all matching items.
Args:
filter_num (int): Filter number (1 for Unterrichtsfach, 2 for Jahrgangsstufe)
old_value (str): Value to replace
new_value (str): New value
Returns:
bool: True if value was updated, False otherwise
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
filter_presets = db['filter_presets']
# Check if the new value already exists
existing = filter_presets.find_one({
'filter_num': filter_num,
'values': new_value
})
if existing and old_value != new_value:
client.close()
return False
# Update the value in the filter
result = filter_presets.update_one(
{'filter_num': filter_num, 'values': old_value},
{'$set': {'values.$': new_value}}
)
if result.modified_count > 0:
items = db['items']
filter_field = 'Filter' if filter_num == 1 else f'Filter{filter_num}'
# Also update all items that use this filter
items.update_many(
{filter_field: old_value},
{'$set': {filter_field: new_value}}
)
client.close()
return result.modified_count > 0
@@ -862,22 +907,22 @@ def set_filter_name(filter_num, name):
def get_predefined_locations():
"""
Get list of all predefined locations/placement options.
Returns:
list: List of predefined location strings
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
# Check if settings collection exists, create if not
if 'settings' not in db.list_collection_names():
db.create_collection('settings')
# Get settings document or create if it doesn't exist
settings_collection = db['settings']
location_settings = settings_collection.find_one({'setting_type': 'predefined_locations'})
if not location_settings:
# Create default settings document if it doesn't exist
settings_collection.insert_one({
@@ -885,12 +930,12 @@ def get_predefined_locations():
'locations': []
})
return []
# Return the predefined locations
locations = location_settings.get('locations', [])
client.close()
return sorted(locations)
except Exception as e:
print(f"Error getting predefined locations: {str(e)}")
return []
@@ -899,28 +944,28 @@ def get_predefined_locations():
def add_predefined_location(location):
"""
Add a new predefined location.
Args:
location (str): Location to add
Returns:
bool: True if added successfully, False if already exists
"""
if not location or not isinstance(location, str):
return False
location = location.strip()
if not location:
return False
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
settings_collection = db['settings']
# Check if settings document exists, create if not
location_settings = settings_collection.find_one({'setting_type': 'predefined_locations'})
if not location_settings:
# Create with the new location
settings_collection.insert_one({
@@ -929,22 +974,22 @@ def add_predefined_location(location):
})
client.close()
return True
# Check if location already exists (case-insensitive)
current_locations = location_settings.get('locations', [])
if any(loc.lower() == location.lower() for loc in current_locations):
client.close()
return False
# Add the new location
settings_collection.update_one(
{'setting_type': 'predefined_locations'},
{'$push': {'locations': location}}
)
client.close()
return True
except Exception as e:
print(f"Error adding predefined location: {str(e)}")
return False
@@ -953,29 +998,29 @@ def add_predefined_location(location):
def remove_predefined_location(location):
"""
Remove a predefined location.
Args:
location (str): Location to remove
Returns:
bool: True if removed successfully
"""
if not location:
return False
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
settings_collection = db['settings']
result = settings_collection.update_one(
{'setting_type': 'predefined_locations'},
{'$pull': {'locations': location}}
)
client.close()
return result.modified_count > 0
except Exception as e:
print(f"Error removing predefined location: {str(e)}")
return False
@@ -984,17 +1029,12 @@ def remove_predefined_location(location):
def update_item_next_appointment(item_id, appointment_data):
"""
Update an item with information about its next scheduled appointment.
Args:
item_id (str): ID of the item to update
appointment_data (dict): Appointment information containing:
- date: Date of the appointment
- start_period: Start period number
- end_period: End period number
- user: Username who scheduled the appointment
- notes: Optional notes
- appointment_id: ID of the appointment booking
item_id (str): ID of the item
appointment_data (dict or None): Dictionary containing appointment details
(e.g., user, start_time, end_time) or None to clear it.
Returns:
bool: True if successful, False otherwise
"""
@@ -1002,35 +1042,33 @@ def update_item_next_appointment(item_id, appointment_data):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
# Format the appointment data for storage
# Ensure date is a datetime object for MongoDB storage
appointment_date = appointment_data['date']
if isinstance(appointment_date, datetime.date) and not isinstance(appointment_date, datetime.datetime):
# Convert date to datetime for MongoDB compatibility
appointment_date = datetime.datetime.combine(appointment_date, datetime.time())
next_appointment = {
'date': appointment_date,
'end_date': appointment_data.get('end_date', appointment_date),
'start_period': appointment_data['start_period'],
'end_period': appointment_data['end_period'],
'user': appointment_data['user'],
'notes': appointment_data.get('notes', ''),
'appointment_id': appointment_data['appointment_id'],
'scheduled_at': datetime.datetime.now()
}
update_data = {
'NextAppointment': next_appointment,
'LastUpdated': datetime.datetime.now()
}
# If clearing the appointment
if appointment_data is None:
update_query = {
'$unset': {'NextAppointment': ""},
'$set': {'LastUpdated': datetime.datetime.now()}
}
else:
# Create a copy so we don't mutate the original dictionary passed in
data_to_save = appointment_data.copy()
# Encrypt the user field if it exists to match the decryption logic at the top
if 'user' in data_to_save and data_to_save['user']:
data_to_save['user'] = dp.encrypt_text(data_to_save['user'])
update_query = {
'$set': {
'NextAppointment': data_to_save,
'LastUpdated': datetime.datetime.now()
}
}
result = items.update_one(
{'_id': ObjectId(item_id)},
{'$set': update_data}
update_query
)
client.close()
return result.modified_count > 0
except Exception as e:
@@ -1041,10 +1079,10 @@ def update_item_next_appointment(item_id, appointment_data):
def clear_item_next_appointment(item_id):
"""
Clear the next appointment information from an item.
Args:
item_id (str): ID of the item to update
Returns:
bool: True if successful, False otherwise
"""
@@ -1052,12 +1090,12 @@ def clear_item_next_appointment(item_id):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
result = items.update_one(
{'_id': ObjectId(item_id)},
{'$unset': {'NextAppointment': ""}, '$set': {'LastUpdated': datetime.datetime.now()}}
)
client.close()
return result.modified_count > 0
except Exception as e:
@@ -1068,7 +1106,7 @@ def clear_item_next_appointment(item_id):
def get_items_with_appointments():
"""
Retrieve all items that have scheduled appointments.
Returns:
list: List of items with NextAppointment field
"""
@@ -1076,7 +1114,7 @@ def get_items_with_appointments():
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
items_return = items.find({'NextAppointment': {'$exists': True}, 'Deleted': {'$ne': True}})
items_list = []
for item in items_return:
@@ -1088,31 +1126,25 @@ def get_items_with_appointments():
print(f"Error retrieving items with appointments: {e}")
return []
def get_current_status(item_id):
def get_current_status(item_id, decrypt=True):
"""
Retrieve the current status of an item, including availability and user.
Args:
item_id (str): ID of the item to check
Returns:
dict: Current status of the item or None if not found
Retrieve the current status of an item, decrypting the user field if present.
"""
oid = _to_object_id(item_id)
if not oid:
return None
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['items']
item = items.find_one({'_id': ObjectId(item_id)}, {'Verfuegbar': 1, 'User': 1})
item = items.find_one({'_id': oid}, {'Verfuegbar': 1, 'User': 1})
if item:
# Convert ObjectId to string for consistency
item['_id'] = str(item['_id'])
client.close()
if decrypt:
decrypt_item_user_data(item)
return item
else:
client.close()
return None
return None
except Exception as e:
print(f"Error retrieving current status: {e}")
print(f"Error retrieving current status for item {item_id}: {e}")
return None
+135 -252
View File
@@ -20,6 +20,7 @@ import string
from bson.objectid import ObjectId
import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient
import Web.modules.inventarsystem.data_protection as dp
import hmac
import os
@@ -109,13 +110,6 @@ def build_username_from_name(first_name, last_name=''):
"""
Build a deterministic username abbreviation from first and last name.
Uses 3 letters from each name and stores it lowercase.
Args:
first_name (str): First name
last_name (str): Last name (optional)
Returns:
str: Generated username
"""
alias = build_name_synonym(first_name, last_name)
return alias.lower()
@@ -324,7 +318,6 @@ def get_effective_permissions(username):
return build_default_permission_payload('full_access')
preset_key = user.get('PermissionPreset')
print(preset_key)
payload = build_default_permission_payload(preset_key)
payload['actions'] = _normalize_bool_map(user.get('ActionPermissions', {}), payload['actions'])
payload['pages'] = _normalize_bool_map(user.get('PagePermissions', {}), payload['pages'])
@@ -352,10 +345,10 @@ def update_user_permissions(username, preset_key, action_permissions=None, page_
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
users = db['users']
result = users.update_one({'Username': username}, {'$set': update_data})
result = users.update_one({'Username': dp.encrypt_text(username)}, {'$set': update_data})
if result.matched_count == 0:
result = users.update_one({'username': username}, {'$set': update_data})
result = users.update_one({'username': dp.encrypt_text(username)}, {'$set': update_data})
client.close()
return result.matched_count > 0
@@ -367,7 +360,7 @@ def get_favorites(username):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'Username': username}) or users.find_one({'username': username})
user = users.find_one({'Username': dp.encrypt_text(username)}) or users.find_one({'username': dp.encrypt_text(username)})
client.close()
if not user:
return []
@@ -382,7 +375,7 @@ def add_favorite(username, item_id):
db = _get_tenant_db(client)
users = db['users']
users.update_one(
{'$or': [{'Username': username}, {'username': username}]},
{'$or': [{'Username': dp.encrypt_text(username)}, {'username': dp.encrypt_text(username)}]},
{'$addToSet': {'favorites': ObjectId(item_id)}}
)
client.close()
@@ -397,7 +390,7 @@ def remove_favorite(username, item_id):
db = _get_tenant_db(client)
users = db['users']
users.update_one(
{'$or': [{'Username': username}, {'username': username}]},
{'$or': [{'Username': dp.encrypt_text(username)}, {'username': dp.encrypt_text(username)}]},
{'$pull': {'favorites': ObjectId(item_id)}}
)
client.close()
@@ -406,16 +399,9 @@ def remove_favorite(username, item_id):
return False
def check_password_strength(password):
"""
Check if a password meets minimum security requirements.
Args:
password (str): Password to check
Returns:
bool: True if password is strong enough, False otherwise
"""
if password is None:
return False
@@ -435,19 +421,15 @@ def check_password_strength(password):
def hashing(password, salt=None):
"""
Hasht ein Passwort mit scrypt.
- Wenn kein Salt übergeben wird, wird ein sicherer, zufälliger Salt generiert (für neue Passwörter).
- Format für neue Hashes: v1$<salt_hex>$<hash_hex>
Hasht ein Passwort mit scrypt.
"""
password_bytes = password.encode('utf-8') # Explizit UTF-8 für Plattformunabhängigkeit
password_bytes = password.encode('utf-8')
if salt is None:
# Neuer Benutzer / Passwortänderung -> Dynamischer Salt
random_salt = os.urandom(16)
hashed = hashlib.scrypt(password_bytes, salt=random_salt, n=16384, r=8, p=1)
return f"v1${random_salt.hex()}${hashed.hex()}"
else:
# Bestehender Benutzer (wird zur Verifizierung aufgerufen)
hashed = hashlib.scrypt(password_bytes, salt=salt, n=16384, r=8, p=1)
return hashed.hex()
@@ -455,25 +437,20 @@ def hashing(password, salt=None):
def verify_password(provided_password, stored_password_string):
"""
Verifiziert ein Passwort gegen einen gespeicherten Hash-String.
Unterstützt das alte Format (statischer Salt) und das neue Format (v1$...).
"""
if not stored_password_string:
return False
# Überprüfung für das neue, sichere Format
if stored_password_string.startswith("v1$"):
try:
_, salt_hex, hash_hex = stored_password_string.split("$")
salt_bytes = bytes.fromhex(salt_hex)
# Berechne den Hash des eingegebenen Passworts mit dem extrahierten Salt
calculated_hash = hashing(provided_password, salt=salt_bytes)
# Timing-Attack-sicherer Vergleich
return hmac.compare_digest(calculated_hash, hash_hex)
except (ValueError, TypeError):
logger.error("Ungültiges Hash-Format in der Datenbank entdeckt.")
return False
else:
# Abwärtskompatibilität: Altes Format mit statischem Salt b'some_salt'
old_static_salt = b'some_salt'
calculated_hash = hashing(provided_password, salt=old_static_salt)
return hmac.compare_digest(calculated_hash, stored_password_string)
@@ -494,22 +471,26 @@ def check_nm_pwd(username, password):
try:
db = client[db_name]
users = db['users']
query = {'$or': [{'Username': username}, {'username': username}]}
query = {'$or': [{'Username': dp.encrypt_text(username)}, {'username': dp.encrypt_text(username)}]}
user_record = users.find_one(query)
if user_record is None:
logger.warning("Kein Benutzer für %r in DB %r gefunden.", username, db_name)
return None
query = {'$or': [{'Username': username}, {'username': username}]}
user_record_fallback = users.find_one(query)
if user_record_fallback is None:
logger.warning("Kein Benutzer für %r in DB %r gefunden.", dp.encrypt_text(username), db_name)
return None
else:
user_record = user_record_fallback
stored_password = user_record.get('Password') or user_record.get('password')
if not verify_password(password, stored_password):
logger.warning("Falsches Passwort für Benutzer %r in DB %r.", username, db_name)
logger.warning("Falsches Passwort für Benutzer %r in DB %r.", dp.encrypt_text(username), db_name)
return None
# Automatische Migration alter Hashes auf das neue Format
if not stored_password.startswith("v1$"):
if stored_password and not stored_password.startswith("v1$"):
users.update_one({'_id': user_record['_id']}, {'$set': {'Password': hashing(password)}})
return user_record
@@ -531,40 +512,35 @@ def add_user(
):
"""
Add a new user to the database.
Args:
username (str): Username for the new user
password (str): Password for the new user
Returns:
bool: True if user was added successfully, False if password was too weak
"""
if not check_password_strength(password):
return False
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
try:
db = _get_tenant_db(client)
users = db['users']
if not check_password_strength(password):
return False
permission_defaults = build_default_permission_payload(permission_preset)
if isinstance(action_permissions, dict):
for key, value in action_permissions.items():
permission_defaults['actions'][str(key)] = bool(value)
if isinstance(page_permissions, dict):
for key, value in page_permissions.items():
permission_defaults['pages'][str(key)] = bool(value)
if permission_preset == "full_access":
can_admin_preset_based = True
else:
can_admin_preset_based = False
safe_name = name.strip() if name else ''
safe_last_name = last_name.strip() if last_name else ''
user_doc = {
'Username': username,
'Username': dp.encrypt_text(username),
'Password': hashing(password),
'Admin': can_admin_preset_based,
'Admin': (permission_preset == "full_access"),
'active_ausleihung': None,
'name': name.strip() if name else '',
'last_name': last_name.strip() if last_name else '',
'name': dp.encrypt_text(safe_name) if safe_name else '',
'last_name': dp.encrypt_text(safe_last_name) if safe_last_name else '',
'IsStudent': bool(is_student),
'PermissionPreset': permission_defaults['preset'],
'ActionPermissions': permission_defaults['actions'],
@@ -601,7 +577,7 @@ def student_card_exists(student_card_id):
def get_user_by_student_card(student_card_id):
"""Return user by student card id or None."""
"""Return user dict by student card id or None."""
normalized = normalize_student_card_id(student_card_id)
if not normalized:
return None
@@ -610,65 +586,44 @@ def get_user_by_student_card(student_card_id):
users = db['student_cards']
found_user = users.find_one({'SchülerName': normalized})
client.close()
# Do not call dp.decrypt_text() here because found_user is a MongoDB dictionary.
return found_user
def make_admin(username):
"""
Grant administrator privileges to a user.
Args:
username (str): Username of the user to promote
Returns:
bool: True if user was promoted successfully
"""
"""Grant administrator privileges to a user."""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
users = db['users']
result = users.update_one({'Username': username}, {'$set': {'Admin': True}})
result = users.update_one({'Username': dp.encrypt_text(username)}, {'$set': {'Admin': True}})
if result.matched_count == 0:
result = users.update_one({'username': username}, {'$set': {'Admin': True}})
result = users.update_one({'username': dp.encrypt_text(username)}, {'$set': {'Admin': True}})
client.close()
return result.matched_count > 0
def remove_admin(username):
"""
Remove administrator privileges from a user.
Args:
username (str): Username of the user to demote
Returns:
bool: True if user was demoted successfully
"""
"""Remove administrator privileges from a user."""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
users = db['users']
result = users.update_one({'Username': username}, {'$set': {'Admin': False}})
result = users.update_one({'Username': dp.encrypt_text(username)}, {'$set': {'Admin': False}})
if result.matched_count == 0:
result = users.update_one({'username': username}, {'$set': {'Admin': False}})
result = users.update_one({'username': dp.encrypt_text(username)}, {'$set': {'Admin': False}})
client.close()
return result.matched_count > 0
def get_user(username):
"""
Retrieve a specific user by username.
Args:
username (str): Username to search for
Returns:
dict: User document or None if not found
"""
"""Retrieve a specific user by username."""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
try:
def find_in_db(database_name):
db = client[database_name]
users = db['users']
return users.find_one({'Username': username}) or users.find_one({'username': username})
return users.find_one({'Username': dp.encrypt_text(username)}) or users.find_one({'username': dp.encrypt_text(username)}) or users.find_one({'username': username}) or users.find_one({'Username': username})
# Try current tenant first when available
tenant_db, tenant_id = _resolve_request_tenant_db()
if tenant_db:
user = find_in_db(tenant_db)
@@ -681,7 +636,6 @@ def get_user(username):
)
return None
# Fallback to default configured database
user = find_in_db(cfg.MONGODB_DB)
if user:
return user
@@ -692,147 +646,89 @@ def get_user(username):
def check_admin(username):
"""
Check if a user has administrator privileges.
Args:
username (str): Username to check
Returns:
bool: True if user is an administrator, False otherwise
"""
"""Check if a user has administrator privileges."""
user = get_user(username)
return bool(user and user.get('Admin', False))
def update_active_ausleihung(username, id_item, ausleihung):
"""
Update a user's active borrowing record.
Args:
username (str): Username of the user
id_item (str): ID of the borrowed item
ausleihung (str): ID of the borrowing record
Returns:
bool: True if successful
"""
"""Update a user's active borrowing record."""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
users = db['users']
users.update_one({'Username': username}, {'$set': {'active_ausleihung': {'Item': id_item, 'Ausleihung': ausleihung}}})
result = users.update_one(
{'Username': dp.encrypt_text(username)},
{'$set': {'active_ausleihung': {'Item': id_item, 'Ausleihung': ausleihung}}}
)
if result.matched_count == 0:
users.update_one(
{'username': dp.encrypt_text(username)},
{'$set': {'active_ausleihung': {'Item': id_item, 'Ausleihung': ausleihung}}}
)
client.close()
return True
def get_active_ausleihung(username):
"""
Get a user's active borrowing record.
Args:
username (str): Username of the user
Returns:
dict: Active borrowing information or None
"""
"""Get a user's active borrowing record."""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'Username': username})
return user['active_ausleihung']
user = users.find_one({'Username': dp.encrypt_text(username)}) or users.find_one({'username': dp.encrypt_text(username)})
client.close()
if not user:
return None
return user.get('active_ausleihung')
def has_active_borrowing(username):
"""
Check if a user currently has an active borrowing.
Args:
username (str): Username to check
Returns:
bool: True if user has an active borrowing, False otherwise
"""
"""Check if a user currently has an active borrowing."""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'username': username})
if not user:
user = users.find_one({'Username': username})
if not user:
client.close()
return False
has_active = user.get('active_borrowing', False)
user = users.find_one({'username': dp.encrypt_text(username)}) or users.find_one({'Username': dp.encrypt_text(username)})
client.close()
return has_active
if not user:
return False
return user.get('active_borrowing', False)
except Exception as e:
return False
def delete_user(username):
"""
Delete a user from the database.
Administrative function for removing user accounts.
Args:
username (str): Username of the account to delete
Returns:
bool: True if user was deleted successfully, False otherwise
"""
"""Delete a user from the database."""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
users = db['users']
result = users.delete_one({'username': username})
client.close()
result = users.delete_one({'username': dp.encrypt_text(username)})
if result.deleted_count == 0:
# Try with different field name
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
users = db['users']
result = users.delete_one({'Username': username})
client.close()
result = users.delete_one({'Username': dp.encrypt_text(username)})
client.close()
return result.deleted_count > 0
def update_active_borrowing(username, item_id, status):
"""
Update a user's active borrowing status.
Args:
username (str): Username of the user
item_id (str): ID of the borrowed item or None if returning
status (bool): True if borrowing, False if returning
Returns:
bool: True if successful, False on error
"""
"""Update a user's active borrowing status."""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
users = db['users']
result = users.update_one(
{'username': username},
{'$set': {
'active_borrowing': status,
'borrowed_item': item_id if status else None
}}
)
update_data = {'$set': {'active_borrowing': status, 'borrowed_item': item_id if status else None}}
result = users.update_one({'username': dp.encrypt_text(username)}, update_data)
if result.matched_count == 0:
result = users.update_one(
{'Username': username},
{'$set': {
'active_borrowing': status,
'borrowed_item': item_id if status else None
}}
)
result = users.update_one({'Username': dp.encrypt_text(username)}, update_data)
client.close()
return result.modified_count > 0
except Exception as e:
@@ -840,43 +736,37 @@ def update_active_borrowing(username, item_id, status):
def get_name(username):
"""
Retrieve the name that is assosiated with the username.
Returns:
str: String of name
"""
"""Retrieve the name that is associated with the username."""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'Username': username})
name = user.get("name")
return name
user = users.find_one({'Username': dp.encrypt_text(username)}) or users.find_one({'username': dp.encrypt_text(username)})
client.close()
if not user or not user.get("name"):
return ""
return dp.decrypt_text(user.get("name"))
def get_last_name(username):
"""
Retrieve the last_name that is assosiated with the username.
Returns:
str: String of last_name
"""
"""Retrieve the last_name that is associated with the username."""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'Username': username})
name = user.get("last_name")
return name
user = users.find_one({'Username': dp.encrypt_text(username)}) or users.find_one({'username': dp.encrypt_text(username)})
client.close()
if not user or not user.get("last_name"):
return ""
return dp.decrypt_text(user.get("last_name"))
def get_all_users():
"""
Retrieve all users from the database.
Administrative function for user management.
Returns:
list: List of all user documents
"""
"""Retrieve all users from the database."""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
@@ -887,65 +777,58 @@ def get_all_users():
except Exception as e:
return []
def update_password(username, new_password):
"""
Update a user's password with a new one.
Args:
username (str): Username of the user
new_password (str): New password to set
Returns:
bool: True if password was updated successfully, False otherwise
"""
"""Update a user's password with a new one."""
try:
if not check_password_strength(new_password):
return False
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
users = db['users']
# Hash the new password
hashed_password = hashing(new_password)
# Update the user's password
result = users.update_one(
{'Username': username},
{'Username': dp.encrypt_text(username)},
{'$set': {'Password': hashed_password}}
)
if result.matched_count == 0:
result = users.update_one(
{'username': dp.encrypt_text(username)},
{'$set': {'Password': hashed_password}}
)
client.close()
return result.modified_count > 0
except Exception as e:
print(f"Error updating password: {e}")
return False
def update_user_name(username, name, last_name):
"""
Update a user's name and last name.
Args:
username (str): Username of the user
name (str): New first name
last_name (str): New last name
Returns:
bool: True if updated successfully, False otherwise
"""
"""Update a user's name and last name."""
try:
name_alias = build_name_synonym(name, last_name)
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
users = db['users']
safe_name = dp.encrypt_text(name.strip()) if name else ''
safe_last_name = dp.encrypt_text(last_name.strip()) if last_name else ''
result = users.update_one(
{'Username': username},
{'$set': {'name': name_alias, 'last_name': ''}}
{'Username': dp.encrypt_text(username)},
{'$set': {'name': safe_name, 'last_name': safe_last_name}}
)
if result.matched_count == 0:
result = users.update_one(
{'username': dp.encrypt_text(username)},
{'$set': {'name': safe_name, 'last_name': safe_last_name}}
)
client.close()
return True
except Exception as e:
print(f"Error updating user name: {e}")
return False
return False
+2 -2
View File
@@ -37,14 +37,14 @@ def send(email: list | str, subject: str, note: str, sender: str) -> bool:
<tr>
<td>
<p style="margin:0 0 12px 0;">Mit freundlichen Grüßen</p>
<p style="margin:0;"><strong style="font-size:16px;">Automatisierter Email Verteiler für die Schule: {cfg.get_school_info().get("name")}</strong><br></p>
<p style="margin:0;"><strong style="font-size:16px;">Automatisierter Email Verteiler für die Schule: {cfg.get_school_info().get("name")}</strong><br></p><br>
<p style="margin:12px 0 0 0;"><strong>Invario UG</strong><br>Am Sportplatz 10<br>83052 Bruckmühl</p>
</td>
</tr>
</table>
"""
text_content = f"{body_message}\n\nMit freundlichen Grüßen\n{sender}\nInvario UG"
text_content = f"{body_message}\n\nMit freundlichen Grüßen\n{sender}\n"
html_content = f"""
<html>
+2 -2
View File
@@ -252,8 +252,8 @@ def new(date_start: str, date_end: str, time_span: list, slots, slot_length, use
if calendar_link:
email_body += f"\n\nKalendereintrag: {calendar_link}"
if normalized_mail and cfg.EMAIL_ENABLED:
mail_service.send(normalized_mail, subject, email_body)
#if normalized_mail and cfg.EMAIL_ENABLED:
mail_service.send(normalized_mail, subject, email_body, f"Terminplanungssystem {cfg.SCHOOL_INFO_DEFAULT.get("name")}")
return {
'appointment_id': id_str,
+49 -36
View File
@@ -40,14 +40,18 @@ try:
else:
vapid = Vapid.from_file(VAPID_PRIVATE_PEM)
# Extract public key in standard uncompressed point format encoded in URL-safe base64
raw_pub = vapid.public_key.public_bytes(
serialization.Encoding.X962,
serialization.PublicFormat.UncompressedPoint
)
VAPID_PUBLIC_KEY = b64urlencode(raw_pub).decode('utf-8')
VAPID_PRIVATE_KEY = VAPID_PRIVATE_PEM # pywebpush accepts the file path to the private PEM
encoded_pub = b64urlencode(raw_pub)
if isinstance(encoded_pub, bytes):
VAPID_PUBLIC_KEY = encoded_pub.decode('utf-8')
else:
VAPID_PUBLIC_KEY = encoded_pub
VAPID_PRIVATE_KEY = VAPID_PRIVATE_PEM
except Exception as e:
logger.error(f'Could not load or generate VAPID keys: {e}')
@@ -118,29 +122,33 @@ def get_user_subscriptions(username):
def save_push_subscription(username, subscription_obj):
"""
Save a new push subscription for a user with field-level encryption.
"""
import traceback # Hilft uns, Fehler genau zu sehen
try:
print("--- DEBUG PUSH PAYLOAD ---")
print(subscription_obj)
endpoint = subscription_obj.get('endpoint')
if not endpoint:
logger.warning('Invalid subscription object: missing endpoint')
keys = subscription_obj.get('keys', {})
if not endpoint or not keys.get('p256dh') or not keys.get('auth'):
print(
f"DEBUG FEHLER: Keys fehlen! Endpoint: {bool(endpoint)}, p256dh: {bool(keys.get('p256dh'))}, auth: {bool(keys.get('auth'))}")
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 using plaintext data to avoid duplicates
sub_hash = hashlib.shake_256(
f"{username}:{endpoint}".encode('utf-8')
).hexdigest()
).hexdigest(32)
# Check if subscription already exists by Hash
existing = subs_col.find_one({
'SubscriptionHash': sub_hash
})
if existing:
subs_col.update_one(
{'_id': existing['_id']},
@@ -152,10 +160,10 @@ def save_push_subscription(username, subscription_obj):
logger.info('Updated existing push subscription')
client.close()
return True
# 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 = {
'UsernameHash': _get_username_hash(username),
@@ -168,39 +176,37 @@ def save_push_subscription(username, subscription_obj):
'LastUsed': datetime.datetime.now(),
'UserAgent': subscription_obj.get('userAgent', ''),
}
subs_col.insert_one(subscription_doc)
logger.info('Saved new encrypted push subscription')
client.close()
return True
except Exception as e:
logger.error(f'Error saving push subscription: {e}')
print(f"DEBUG ABSTURZ in save_push_subscription: {e}")
traceback.print_exc()
return False
def remove_push_subscription(username, endpoint):
"""
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()
).hexdigest(32)
result = subs_col.update_one(
{'SubscriptionHash': sub_hash},
{'$set': {'IsActive': False}}
)
client.close()
return result.modified_count > 0
return result.matched_count > 0
except Exception as e:
logger.error(f'Error removing push subscription: {e}')
return False
@@ -308,8 +314,8 @@ def _send_fcm_notification(subscription, payload):
def _send_web_push_notification(subscription, payload):
try:
from pywebpush import webpush
from pywebpush import webpush, WebPushException
webpush(
subscription_info={
'endpoint': subscription['Endpoint'],
@@ -319,18 +325,25 @@ def _send_web_push_notification(subscription, payload):
vapid_private_key=VAPID_PRIVATE_KEY,
vapid_claims={'sub': VAPID_SUBJECT},
timeout=10,
ttl=3600
ttl=3600
)
return True
except ImportError:
logger.warning('pywebpush not installed. pip install pywebpush')
return False
except Exception as e:
logger.error(f'Web push error: {e}')
return False
# HÄRTUNG: Spezifische WebPush-Fehler abfangen
except WebPushException as ex:
# HTTP 404 (Not Found) oder 410 (Gone) bedeuten: Abo existiert nicht mehr
if ex.response is not None and ex.response.status_code in [404, 410]:
logger.info(f"Subscription expired or revoked (Code {ex.response.status_code}). Marking inactive.")
return False # False signalisiert der übergeordneten Funktion, das Abo zu deaktivieren
logger.error(f'Web push failed with code {ex.response.status_code if ex.response else "Unknown"}: {ex}')
return False
except Exception as e:
logger.error(f'Unexpected Web push error: {e}')
return False
def _mark_subscription_inactive(subscription_id):
try:
+2 -1
View File
@@ -16,4 +16,5 @@ openpyxl
cryptography>=42.0.0
pywebpush
py-vapid>=1.9.0
beautifulsoup4
beautifulsoup4
pywebpush
+23 -14
View File
@@ -25,7 +25,7 @@ class PushNotificationManager {
* Initialize push notification system
* Must be called after page load
*/
async init() {
async init(providedKey = null) {
if (!this.isSupported) {
console.log('Push notifications not supported in this browser');
return false;
@@ -49,11 +49,18 @@ class PushNotificationManager {
}
}
// Fetch VAPID public key from server
// Wenn der Key direkt aus dem Template übergeben wurde, nutze diesen (spart einen Request)
if (providedKey) {
this.vapidKey = providedKey;
return true;
}
// Ansonsten: Fetch VAPID public key from server
const keyResponse = await fetch('/api/push/vapid-key');
if (keyResponse.ok) {
const keyData = await keyResponse.json();
this.vapidKey = keyData.vapid_key;
// WICHTIG: Muss exakt mit dem Python-JSON-Key übereinstimmen!
this.vapidKey = keyData.publicKey;
} else {
console.warn('Failed to fetch VAPID key');
return false;
@@ -154,20 +161,22 @@ class PushNotificationManager {
try {
const subscription = await this.serviceWorkerRegistration.pushManager.getSubscription();
if (!subscription) {
console.warn('No active push subscription');
return false;
}
// Remove subscription on server
const success = await this.removeSubscriptionFromServer(subscription);
// Unsubscribe from push service
if (success) {
await subscription.unsubscribe();
return true;
}
return false;
// Best-Effort: Server benachrichtigen (Eigener try/catch Block!)
try {
await this.removeSubscriptionFromServer(subscription);
} catch (serverError) {
// Fehler vom Server ignorieren wir absichtlich.
// Das Skript läuft weiter, statt hier abzubrechen!
console.warn('Server-Abmeldung fehlgeschlagen, lösche lokal trotzdem:', serverError);
}
// WICHTIG: Das hier wird jetzt garantiert ausgeführt
await subscription.unsubscribe();
return true;
} catch (error) {
console.error('Failed to unsubscribe from push notifications:', error);
return false;
+5 -5
View File
@@ -1148,7 +1148,7 @@
{% if current_permissions.pages.get('tutorial_page', False) %}
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
{% endif %}
{% if current_permissions.pages.get('admin_school_settings', False) %}
{% if current_permissions.actions.get('can_manage_settings', False) %}
<li><a class="dropdown-item" href="{{ url_for('admin_school_settings') }}">Schulstammdaten</a></li>
{% endif %}
{% if current_permissions.actions.get('can_view_logs', False) or current_permissions.pages.get('admin_audit_dashboard', False) %}
@@ -1257,7 +1257,7 @@
{% if current_permissions.pages.get('manage_locations', False) %}
<li><a class="dropdown-item" href="{{ url_for('manage_locations') }}">Orte verwalten</a></li>
{% endif %}
{% if current_permissions.pages.get('admin_school_settings', False) %}
{% if current_permissions.actions.get('can_manage_settings', False) %}
<li><a class="dropdown-item" href="{{ url_for('admin_school_settings') }}">Schulstammdaten</a></li>
{% endif %}
{% if current_permissions.pages.get('admin_borrowings', False) %}
@@ -1379,7 +1379,7 @@
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Bibliotheksausweis</a></li>
{% endif %}
{% endif %}
{% if current_permissions.pages.get('admin_school_settings', False) %}
{% if current_permissions.actions.get('can_manage_settings', False) %}
<li><a class="dropdown-item" href="{{ url_for('admin_school_settings') }}">Schulstammdaten</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
@@ -1698,7 +1698,7 @@
<option value="Orte verwalten"></option>
{% endif %}
{% if current_permissions.pages.get('admin_school_settings', False) %}
{% if current_permissions.actions.get('can_manage_settings', False) %}
<option value="Schulstammdaten"></option>
{% endif %}
@@ -1823,7 +1823,7 @@
{ label: 'Orte verwalten', keywords: ['orte verwalten', 'orte', 'location'], url: {{ url_for('manage_locations')|tojson }} },
{% endif %}
{% if current_permissions.pages.get('admin_school_settings', False) %}
{% if current_permissions.actions.get('can_manage_settings', False) %}
{ label: 'Schulstammdaten', keywords: ['schule', 'settings', 'stammdaten', 'school settings'], url: {{ url_for('admin_school_settings')|tojson }} },
{% endif %}
+5 -4
View File
@@ -200,6 +200,7 @@ sys.path.insert(0, "/app")
sys.path.insert(0, "/app/Web")
from Web.modules.database import settings
from pymongo import MongoClient
import Web.modules.inventarsystem.data_protection as dp
tenant_id = sys.argv[1].lower()
mode = sys.argv[2]
@@ -244,14 +245,14 @@ page_permissions = {
"manage_locations": True,
}
if db.users.count_documents({"Username": "admin"}) == 0:
if db.users.count_documents({"Username": dp.encrypt_text("admin")}) == 0:
db.users.insert_one({
"Username": "admin",
"Username": dp.encrypt_text("admin"),
"Password": hashed_pw_string,
"Admin": True,
"active_ausleihung": None,
"name": "Admin",
"last_name": "User",
"name": dp.encrypt_text("Admin"),
"last_name": dp.encrypt_text("User"),
"IsStudent": False,
"PermissionPreset": "full_access",
"ActionPermissions": action_permissions,
+2 -1
View File
@@ -16,4 +16,5 @@ openpyxl
cryptography>=42.0.0
pywebpush
py-vapid>=1.9.0
beautifulsoup4
beautifulsoup4
pywebpush