Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6dda0f25a | |||
| 46176c1741 | |||
| c57b4a9a44 | |||
| 6f424cc8aa | |||
| 103b82af15 | |||
| 69c8aa8700 | |||
| 1fa3dace17 | |||
| 6dced8b1a5 | |||
| 493ee2ea7f | |||
| 52a2ec0cad | |||
| b5f2c3c5c5 | |||
| 403c281c93 | |||
| 6d121f6110 | |||
| 9d940c6151 | |||
| 980b825e07 | |||
| 94326728eb | |||
| efe17883a6 | |||
| 76f93b3d2e | |||
| 8f793045c2 | |||
| a199439d76 | |||
| 6cec483390 | |||
| 10e2f7cc17 | |||
| 0f8c5a0fec | |||
| 091147ecfd | |||
| 7bedfc1558 | |||
| 7e258e07ab | |||
| 96245bb06b | |||
| 245c3c19dd | |||
| d76c69d836 | |||
| 9527fc10cf |
+210
-201
File diff suppressed because it is too large
Load Diff
@@ -1235,5 +1235,5 @@ def reset_item_completely(item_id):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return {
|
||||||
'success': False,
|
'success': False,
|
||||||
'message': f'Fehler beim Zurücksetzen: {str(e)}'
|
'message': f'Fehler beim Zurücksetzen.'
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,7 @@ import Web.modules.database.settings as cfg
|
|||||||
from Web.modules.database.settings import MongoClient
|
from Web.modules.database.settings import MongoClient
|
||||||
|
|
||||||
|
|
||||||
LIBRARY_ITEM_TYPES = ('book', 'cd', 'dvd', 'media')
|
LIBRARY_ITEM_TYPES = ('book', 'cd', 'dvd', 'media', 'schulbuch')
|
||||||
|
|
||||||
|
|
||||||
def _non_library_query(extra_query=None):
|
def _non_library_query(extra_query=None):
|
||||||
@@ -49,7 +49,7 @@ def add_item(name, ort, beschreibung, images=None, filter=None, filter2=None, fi
|
|||||||
ansch_jahr=None, ansch_kost=None, code_4=None, reservierbar=True,
|
ansch_jahr=None, ansch_kost=None, code_4=None, reservierbar=True,
|
||||||
series_group_id=None, series_count=1, series_position=1,
|
series_group_id=None, series_count=1, series_position=1,
|
||||||
is_grouped_sub_item=False, parent_item_id=None,
|
is_grouped_sub_item=False, parent_item_id=None,
|
||||||
isbn=None, item_type='general'):
|
isbn=None, item_type='general', library_category=None):
|
||||||
"""
|
"""
|
||||||
Add a new item to the inventory.
|
Add a new item to the inventory.
|
||||||
|
|
||||||
@@ -70,6 +70,9 @@ def add_item(name, ort, beschreibung, images=None, filter=None, filter2=None, fi
|
|||||||
series_position (int, optional): Position inside the batch (1-based)
|
series_position (int, optional): Position inside the batch (1-based)
|
||||||
is_grouped_sub_item (bool, optional): Whether this item is hidden as sub-item
|
is_grouped_sub_item (bool, optional): Whether this item is hidden as sub-item
|
||||||
parent_item_id (str, optional): Parent item id if this is a sub-item
|
parent_item_id (str, optional): Parent item id if this is a sub-item
|
||||||
|
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:
|
Returns:
|
||||||
ObjectId: ID of the new item or None if failed
|
ObjectId: ID of the new item or None if failed
|
||||||
@@ -97,6 +100,7 @@ def add_item(name, ort, beschreibung, images=None, filter=None, filter2=None, fi
|
|||||||
'Anschaffungskosten': ansch_kost,
|
'Anschaffungskosten': ansch_kost,
|
||||||
'Code_4': code_4,
|
'Code_4': code_4,
|
||||||
'ISBN': isbn,
|
'ISBN': isbn,
|
||||||
|
'library_category': library_category,
|
||||||
'ItemType': item_type,
|
'ItemType': item_type,
|
||||||
'SeriesGroupId': series_group_id,
|
'SeriesGroupId': series_group_id,
|
||||||
'SeriesCount': series_count,
|
'SeriesCount': series_count,
|
||||||
@@ -194,69 +198,61 @@ def get_group_item_ids(id):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def update_item(id, name, ort, beschreibung, images=None, verfuegbar=True,
|
def update_item(id, name, ort, beschreibung, images, verfuegbar, filter1, filter2, filter3,
|
||||||
filter=None, filter2=None, filter3=None, ansch_jahr=None, ansch_kost=None, code_4=None, reservierbar=True,
|
ansch_jahr, ansch_kost, code_4, reservierbar, isbn=None, item_type='general'):
|
||||||
isbn=None, item_type='general'):
|
|
||||||
"""
|
|
||||||
Update an existing inventory item.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
id (str): ID of the item to update
|
|
||||||
name (str): Name of the item
|
|
||||||
ort (str): Location of the item
|
|
||||||
beschreibung (str): Description of the item
|
|
||||||
images (list, optional): List of image filenames for the item
|
|
||||||
verfuegbar (bool, optional): Availability status of the item
|
|
||||||
filter (str, optional): Primary filter/category for the item
|
|
||||||
filter2 (str, optional): Secondary filter/category for the item
|
|
||||||
filter3 (str, optional): Tertiary filter/category for the item
|
|
||||||
ansch_jahr (int, optional): Year of acquisition
|
|
||||||
ansch_kost (float, optional): Cost of acquisition
|
|
||||||
code_4 (str, optional): 4-digit identification code
|
|
||||||
reservierbar (bool, optional): Whether the item can be reserved in advance
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if successful, False otherwise
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = client[cfg.MONGODB_DB]
|
db = client[cfg.MONGODB_DB]
|
||||||
items = db['items']
|
items = db['items']
|
||||||
|
|
||||||
# Set default values for optional parameters
|
# 1. Altes Item laden, um SeriesGroupId zu bestimmen
|
||||||
if images is None:
|
old_item = items.find_one({'_id': ObjectId(id)})
|
||||||
images = []
|
if not old_item:
|
||||||
|
return False
|
||||||
|
|
||||||
|
series_group_id = old_item.get('SeriesGroupId')
|
||||||
|
|
||||||
update_data = {
|
# 2. Shared Data: Daten, die für ALLE in der Gruppe gleich sind
|
||||||
|
shared_update = {
|
||||||
'Name': name,
|
'Name': name,
|
||||||
'Ort': ort,
|
'Ort': ort,
|
||||||
'Beschreibung': beschreibung,
|
'Beschreibung': beschreibung,
|
||||||
'Images': images,
|
'Images': images,
|
||||||
'Verfuegbar': verfuegbar,
|
'Filter': filter1,
|
||||||
'Reservierbar': reservierbar,
|
|
||||||
'Filter': filter,
|
|
||||||
'Filter2': filter2,
|
'Filter2': filter2,
|
||||||
'Filter3': filter3,
|
'Filter3': filter3,
|
||||||
'Anschaffungsjahr': ansch_jahr,
|
'Anschaffungsjahr': ansch_jahr,
|
||||||
'Anschaffungskosten': ansch_kost,
|
'Anschaffungskosten': ansch_kost,
|
||||||
'Code_4': code_4,
|
'Reservierbar': reservierbar,
|
||||||
'ISBN': isbn,
|
'ISBN': isbn,
|
||||||
'ItemType': item_type,
|
'ItemType': item_type,
|
||||||
|
'Verfuegbar': verfuegbar, # Wir behalten den Status bei
|
||||||
'LastUpdated': datetime.datetime.now()
|
'LastUpdated': datetime.datetime.now()
|
||||||
}
|
}
|
||||||
|
|
||||||
result = items.update_one(
|
# 3. Spezifische Daten: Was NICHT synchronisiert wird
|
||||||
{'_id': ObjectId(id)},
|
specific_update = shared_update.copy()
|
||||||
{'$set': update_data}
|
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(
|
||||||
|
{
|
||||||
|
'SeriesGroupId': series_group_id,
|
||||||
|
'_id': {'$ne': ObjectId(id)}
|
||||||
|
},
|
||||||
|
{'$set': shared_update}
|
||||||
|
)
|
||||||
|
|
||||||
client.close()
|
client.close()
|
||||||
return result.modified_count > 0
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error updating item: {e}")
|
print(f"Error updating item: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def update_item_status(id, verfuegbar, user=None):
|
def update_item_status(id, verfuegbar, user=None):
|
||||||
"""
|
"""
|
||||||
Update the availability status of an inventory item.
|
Update the availability status of an inventory item.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
|
from Web.modules.inventarsystem.data_protection import encrypt_document_fields, decrypt_document_fields
|
||||||
|
|
||||||
from pymongo.errors import DuplicateKeyError
|
from pymongo.errors import DuplicateKeyError
|
||||||
|
|
||||||
@@ -24,21 +25,34 @@ def _entry_hash(prev_hash, payload):
|
|||||||
base = f"{prev_hash}|{_stable_json(payload)}"
|
base = f"{prev_hash}|{_stable_json(payload)}"
|
||||||
return hashlib.sha256(base.encode("utf-8")).hexdigest()
|
return hashlib.sha256(base.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
def get_decrypted_audit_logs(db, query=None, decrypt_fields=None):
|
||||||
def append_audit_event(db, event_type, actor, payload, request_ip=None, source="web", max_retries=5):
|
|
||||||
"""
|
"""
|
||||||
Append an audit event to a tamper-evident chain.
|
Retrieve and decrypt audit logs for analysis.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: MongoDB database handle.
|
query (dict): MongoDB query filter.
|
||||||
event_type (str): Event category.
|
decrypt_fields (list): Fields within the 'payload' that should be decrypted.
|
||||||
actor (str): User/system who performed the action.
|
"""
|
||||||
payload (dict): Event details.
|
logs = db["audit_log"]
|
||||||
request_ip (str, optional): Request origin.
|
cursor = logs.find(query or {}).sort("chain_index", 1)
|
||||||
source (str): Source subsystem.
|
|
||||||
|
results = []
|
||||||
|
for entry in cursor:
|
||||||
|
# Decrypt specific fields if provided
|
||||||
|
if decrypt_fields:
|
||||||
|
decrypt_document_fields(entry.get("payload", {}), decrypt_fields)
|
||||||
|
results.append(entry)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Inserted audit entry.
|
def append_audit_event(db, event_type, actor, payload, request_ip=None, source="web", max_retries=5, encrypt_fields=None):
|
||||||
|
"""
|
||||||
|
Append an audit event, optionally encrypting specific payload fields.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
...
|
||||||
|
encrypt_fields (list, optional): List of keys in 'payload' to encrypt.
|
||||||
"""
|
"""
|
||||||
logs = db["audit_log"]
|
logs = db["audit_log"]
|
||||||
attempts = 0
|
attempts = 0
|
||||||
@@ -49,15 +63,24 @@ def append_audit_event(db, event_type, actor, payload, request_ip=None, source="
|
|||||||
chain_index = int(previous.get("chain_index", 0)) + 1 if previous else 1
|
chain_index = int(previous.get("chain_index", 0)) + 1 if previous else 1
|
||||||
|
|
||||||
timestamp = datetime.datetime.utcnow()
|
timestamp = datetime.datetime.utcnow()
|
||||||
|
|
||||||
|
# 1. Create the payload dictionary
|
||||||
|
event_payload = payload or {}
|
||||||
|
|
||||||
|
# 2. Encrypt sensitive fields in-place if requested
|
||||||
|
if encrypt_fields:
|
||||||
|
encrypt_document_fields(event_payload, encrypt_fields)
|
||||||
|
|
||||||
entry_payload = {
|
entry_payload = {
|
||||||
"event_type": event_type,
|
"event_type": event_type,
|
||||||
"actor": actor or "system",
|
"actor": actor or "system",
|
||||||
"source": source,
|
"source": source,
|
||||||
"ip": request_ip or "",
|
"ip": request_ip or "",
|
||||||
"payload": payload or {},
|
"payload": event_payload,
|
||||||
"timestamp": timestamp.isoformat() + "Z",
|
"timestamp": timestamp.isoformat() + "Z",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 3. Hash the payload (which now contains encrypted values)
|
||||||
entry_hash = _entry_hash(prev_hash, entry_payload)
|
entry_hash = _entry_hash(prev_hash, entry_payload)
|
||||||
|
|
||||||
entry = {
|
entry = {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ def log_status_change(ausleihung_id, old_status, new_status, user=None):
|
|||||||
ausleihung_id: Die ID der Ausleihung
|
ausleihung_id: Die ID der Ausleihung
|
||||||
old_status: Der alte Status
|
old_status: Der alte Status
|
||||||
new_status: Der neue Status
|
new_status: Der neue Status
|
||||||
user: Der Benutzer, der die Änderung vorgenommen hat (optional)
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Erstelle Log-Verzeichnis, falls es nicht existiert
|
# Erstelle Log-Verzeichnis, falls es nicht existiert
|
||||||
@@ -34,10 +34,9 @@ def log_status_change(ausleihung_id, old_status, new_status, user=None):
|
|||||||
|
|
||||||
# Protokolliere die Änderung
|
# Protokolliere die Änderung
|
||||||
timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
user_info = f" by {user}" if user else ""
|
|
||||||
|
|
||||||
with open(log_file, 'a', encoding='utf-8') as f:
|
with open(log_file, 'a', encoding='utf-8') as f:
|
||||||
f.write(f"{timestamp}: Ausleihung {ausleihung_id} - Status changed from '{old_status}' to '{new_status}'{user_info}\n")
|
f.write(f"{timestamp}: Ausleihung {ausleihung_id} - Status changed from '{old_status}' to '{new_status}'\n")
|
||||||
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -215,7 +215,8 @@ def client(appointment_id):
|
|||||||
current_user=session.get('username', ''),
|
current_user=session.get('username', ''),
|
||||||
tenant_id=_current_tenant_id(),
|
tenant_id=_current_tenant_id(),
|
||||||
can_view_booking_names=can_view_booking_names,
|
can_view_booking_names=can_view_booking_names,
|
||||||
custom_fields=custom_fields
|
custom_fields=custom_fields,
|
||||||
|
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan')
|
||||||
)
|
)
|
||||||
|
|
||||||
if appointment_service.book_slot(appointment_id, start_daytime, username, custom=custom_answers):
|
if appointment_service.book_slot(appointment_id, start_daytime, username, custom=custom_answers):
|
||||||
@@ -239,7 +240,8 @@ def client(appointment_id):
|
|||||||
tenant_id=_current_tenant_id(),
|
tenant_id=_current_tenant_id(),
|
||||||
can_view_booking_names=can_view_booking_names,
|
can_view_booking_names=can_view_booking_names,
|
||||||
custom_fields=custom_fields,
|
custom_fields=custom_fields,
|
||||||
appointment_item=appointment_item
|
appointment_item=appointment_item,
|
||||||
|
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -356,6 +358,7 @@ def configure():
|
|||||||
add_to_calendar=add_to_calendar,
|
add_to_calendar=add_to_calendar,
|
||||||
email_service_enabled=cfg.EMAIL_ENABLED,
|
email_service_enabled=cfg.EMAIL_ENABLED,
|
||||||
title=title,
|
title=title,
|
||||||
|
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan')
|
||||||
)
|
)
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
@@ -366,6 +369,7 @@ def configure():
|
|||||||
add_to_calendar=False,
|
add_to_calendar=False,
|
||||||
email_service_enabled=cfg.EMAIL_ENABLED,
|
email_service_enabled=cfg.EMAIL_ENABLED,
|
||||||
title=None,
|
title=None,
|
||||||
|
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -553,4 +557,5 @@ def main():
|
|||||||
current_user=current_user,
|
current_user=current_user,
|
||||||
upcoming_events=upcoming_events,
|
upcoming_events=upcoming_events,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
|
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan')
|
||||||
)
|
)
|
||||||
+71
-110
@@ -13,6 +13,7 @@ import logging
|
|||||||
|
|
||||||
import Web.modules.database.settings as cfg
|
import Web.modules.database.settings as cfg
|
||||||
from Web.modules.database.settings import MongoClient
|
from Web.modules.database.settings import MongoClient
|
||||||
|
from Web.modules.inventarsystem.data_protection import encrypt_text, decrypt_text
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -44,16 +45,22 @@ if not VAPID_PUBLIC_KEY or not VAPID_PRIVATE_KEY:
|
|||||||
serialization.PublicFormat.UncompressedPoint
|
serialization.PublicFormat.UncompressedPoint
|
||||||
)
|
)
|
||||||
|
|
||||||
VAPID_PUBLIC_KEY = b64urlencode(raw_pub)
|
VAPID_PUBLIC_KEY = b64urlencode(raw_pub).decode('utf-8')
|
||||||
VAPID_PRIVATE_KEY = VAPID_PRIVATE_PEM
|
VAPID_PRIVATE_KEY = VAPID_PRIVATE_PEM
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Could not load or generate VAPID keys: {e}')
|
logger.error(f'Could not load or generate VAPID keys: {e}')
|
||||||
|
|
||||||
# Push service endpoint (typically Firebase or Web Push Service)
|
# Push service endpoint (typically Firebase or Web Push Service)
|
||||||
PUSH_SERVICE_URL = 'https://fcm.googleapis.com/fcm/send' # Firebase Cloud Messaging
|
|
||||||
FCM_API_KEY = os.getenv('FCM_API_KEY', '') # Firebase API key
|
FCM_API_KEY = os.getenv('FCM_API_KEY', '') # Firebase API key
|
||||||
|
|
||||||
|
|
||||||
|
def _get_username_hash(username):
|
||||||
|
"""Generates a deterministic hash for database lookups."""
|
||||||
|
if not username:
|
||||||
|
return None
|
||||||
|
return hashlib.sha256(username.encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def get_push_subscriptions_collection(db=None):
|
def get_push_subscriptions_collection(db=None):
|
||||||
"""Get MongoDB push subscriptions collection"""
|
"""Get MongoDB push subscriptions collection"""
|
||||||
if db is None:
|
if db is None:
|
||||||
@@ -64,71 +71,68 @@ def get_push_subscriptions_collection(db=None):
|
|||||||
|
|
||||||
def get_user_subscriptions(username):
|
def get_user_subscriptions(username):
|
||||||
"""
|
"""
|
||||||
Get all active push subscriptions for a user
|
Get all active push subscriptions for a user, decrypting data on the fly.
|
||||||
|
|
||||||
Args:
|
|
||||||
username (str): Username
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: List of subscription documents
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = client[cfg.MONGODB_DB]
|
db = client[cfg.MONGODB_DB]
|
||||||
subs_col = get_push_subscriptions_collection(db)
|
subs_col = get_push_subscriptions_collection(db)
|
||||||
|
|
||||||
subscriptions = list(subs_col.find({
|
# Query using the deterministic hash, NOT the encrypted text directly
|
||||||
'Username': username,
|
user_hash = _get_username_hash(username)
|
||||||
|
|
||||||
|
encrypted_subscriptions = list(subs_col.find({
|
||||||
|
'UsernameHash': user_hash,
|
||||||
'IsActive': True
|
'IsActive': True
|
||||||
}))
|
}))
|
||||||
|
|
||||||
client.close()
|
client.close()
|
||||||
return subscriptions
|
|
||||||
|
# Decrypt endpoints and keys before returning
|
||||||
|
decrypted_subs = []
|
||||||
|
for sub in encrypted_subscriptions:
|
||||||
|
try:
|
||||||
|
sub['Endpoint'] = decrypt_text(sub.get('Endpoint'))
|
||||||
|
|
||||||
|
# Keys are stored as encrypted JSON strings
|
||||||
|
decrypted_keys_str = decrypt_text(sub.get('Keys'))
|
||||||
|
sub['Keys'] = json.loads(decrypted_keys_str) if decrypted_keys_str else {}
|
||||||
|
|
||||||
|
decrypted_subs.append(sub)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to decrypt subscription payload for hash {user_hash}: {e}")
|
||||||
|
|
||||||
|
return decrypted_subs
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Error getting push subscriptions for {username}: {e}')
|
logger.error(f'Error getting push subscriptions for user: {e}')
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def save_push_subscription(username, subscription_obj):
|
def save_push_subscription(username, subscription_obj):
|
||||||
"""
|
"""
|
||||||
Save a new push subscription for a user
|
Save a new push subscription for a user with field-level encryption.
|
||||||
|
|
||||||
Args:
|
|
||||||
username (str): Username
|
|
||||||
subscription_obj (dict): Subscription object from Service Worker
|
|
||||||
{
|
|
||||||
'endpoint': 'https://...',
|
|
||||||
'keys': {
|
|
||||||
'p256dh': '...',
|
|
||||||
'auth': '...'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: Success status
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if not subscription_obj.get('endpoint'):
|
endpoint = subscription_obj.get('endpoint')
|
||||||
logger.warning(f'Invalid subscription object for {username}')
|
if not endpoint:
|
||||||
|
logger.warning('Invalid subscription object: missing endpoint')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = client[cfg.MONGODB_DB]
|
db = client[cfg.MONGODB_DB]
|
||||||
subs_col = get_push_subscriptions_collection(db)
|
subs_col = get_push_subscriptions_collection(db)
|
||||||
|
|
||||||
# Create unique hash of subscription to avoid duplicates
|
# Create unique hash of subscription using plaintext data to avoid duplicates
|
||||||
sub_hash = hashlib.md5(
|
sub_hash = hashlib.shake_256(
|
||||||
f"{username}:{subscription_obj['endpoint']}".encode()
|
f"{username}:{endpoint}".encode('utf-8')
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
|
|
||||||
# Check if subscription already exists
|
# Check if subscription already exists by Hash
|
||||||
existing = subs_col.find_one({
|
existing = subs_col.find_one({
|
||||||
'Username': username,
|
|
||||||
'SubscriptionHash': sub_hash
|
'SubscriptionHash': sub_hash
|
||||||
})
|
})
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
# Update last used time
|
|
||||||
subs_col.update_one(
|
subs_col.update_one(
|
||||||
{'_id': existing['_id']},
|
{'_id': existing['_id']},
|
||||||
{'$set': {
|
{'$set': {
|
||||||
@@ -136,15 +140,19 @@ def save_push_subscription(username, subscription_obj):
|
|||||||
'IsActive': True
|
'IsActive': True
|
||||||
}}
|
}}
|
||||||
)
|
)
|
||||||
logger.info(f'Updated existing subscription for {username}')
|
logger.info('Updated existing push subscription')
|
||||||
client.close()
|
client.close()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Save new subscription
|
# Format keys as JSON string for your encrypt_text module
|
||||||
|
keys_str = json.dumps(subscription_obj.get('keys', {}))
|
||||||
|
|
||||||
|
# Save new subscription, encrypting sensitive fields
|
||||||
subscription_doc = {
|
subscription_doc = {
|
||||||
'Username': username,
|
'UsernameHash': _get_username_hash(username),
|
||||||
'Endpoint': subscription_obj['endpoint'],
|
'Username': encrypt_text(username),
|
||||||
'Keys': subscription_obj.get('keys', {}),
|
'Endpoint': encrypt_text(endpoint),
|
||||||
|
'Keys': encrypt_text(keys_str),
|
||||||
'SubscriptionHash': sub_hash,
|
'SubscriptionHash': sub_hash,
|
||||||
'IsActive': True,
|
'IsActive': True,
|
||||||
'CreatedAt': datetime.datetime.now(),
|
'CreatedAt': datetime.datetime.now(),
|
||||||
@@ -153,36 +161,31 @@ def save_push_subscription(username, subscription_obj):
|
|||||||
}
|
}
|
||||||
|
|
||||||
subs_col.insert_one(subscription_doc)
|
subs_col.insert_one(subscription_doc)
|
||||||
logger.info(f'Saved new push subscription for {username}')
|
logger.info('Saved new encrypted push subscription')
|
||||||
client.close()
|
client.close()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Error saving push subscription for {username}: {e}')
|
logger.error(f'Error saving push subscription: {e}')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def remove_push_subscription(username, endpoint):
|
def remove_push_subscription(username, endpoint):
|
||||||
"""
|
"""
|
||||||
Remove a push subscription
|
Remove a push subscription by making it inactive.
|
||||||
|
|
||||||
Args:
|
|
||||||
username (str): Username
|
|
||||||
endpoint (str): Subscription endpoint URL
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: Success status
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = client[cfg.MONGODB_DB]
|
db = client[cfg.MONGODB_DB]
|
||||||
subs_col = get_push_subscriptions_collection(db)
|
subs_col = get_push_subscriptions_collection(db)
|
||||||
|
|
||||||
|
# Recreate the deterministic hash to find the specific subscription
|
||||||
|
sub_hash = hashlib.shake_256(
|
||||||
|
f"{username}:{endpoint}".encode('utf-8')
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
result = subs_col.update_one(
|
result = subs_col.update_one(
|
||||||
{
|
{'SubscriptionHash': sub_hash},
|
||||||
'Username': username,
|
|
||||||
'Endpoint': endpoint
|
|
||||||
},
|
|
||||||
{'$set': {'IsActive': False}}
|
{'$set': {'IsActive': False}}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -190,31 +193,19 @@ def remove_push_subscription(username, endpoint):
|
|||||||
return result.modified_count > 0
|
return result.modified_count > 0
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Error removing push subscription for {username}: {e}')
|
logger.error(f'Error removing push subscription: {e}')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def send_push_notification(username, title, body, icon=None, url='/', reference=None, tag='notification'):
|
def send_push_notification(username, title, body, icon=None, url='/', reference=None, tag='notification'):
|
||||||
"""
|
"""
|
||||||
Send a push notification to all user's subscriptions
|
Send a push notification to all user's subscriptions.
|
||||||
|
|
||||||
Args:
|
|
||||||
username (str): Target username
|
|
||||||
title (str): Notification title
|
|
||||||
body (str): Notification body
|
|
||||||
icon (str, optional): Icon URL
|
|
||||||
url (str, optional): URL to open on click
|
|
||||||
reference (dict, optional): Reference data (item_id, etc)
|
|
||||||
tag (str, optional): Notification tag for grouping
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int: Number of successfully sent notifications
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
subscriptions = get_user_subscriptions(username)
|
subscriptions = get_user_subscriptions(username)
|
||||||
|
|
||||||
if not subscriptions:
|
if not subscriptions:
|
||||||
logger.debug(f'No active push subscriptions for {username}')
|
logger.debug('No active push subscriptions for user')
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
sent_count = 0
|
sent_count = 0
|
||||||
@@ -232,19 +223,18 @@ def send_push_notification(username, title, body, icon=None, url='/', reference=
|
|||||||
if success:
|
if success:
|
||||||
sent_count += 1
|
sent_count += 1
|
||||||
else:
|
else:
|
||||||
# Mark subscription as inactive if send fails
|
|
||||||
_mark_subscription_inactive(subscription['_id'])
|
_mark_subscription_inactive(subscription['_id'])
|
||||||
|
|
||||||
logger.info(f'Sent push notification to {username}: {sent_count}/{len(subscriptions)} subscriptions')
|
logger.info(f'Sent push notification: {sent_count}/{len(subscriptions)} subscriptions')
|
||||||
return sent_count
|
return sent_count
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Error sending push notification to {username}: {e}')
|
logger.error(f'Error sending push notification: {e}')
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _send_to_subscription(subscription, title, body, icon, url, reference, tag):
|
def _send_to_subscription(subscription, title, body, icon, url, reference, tag):
|
||||||
"""Send push notification to a specific subscription"""
|
"""Send push notification to a specific decrypted subscription"""
|
||||||
try:
|
try:
|
||||||
payload = {
|
payload = {
|
||||||
'title': title,
|
'title': title,
|
||||||
@@ -256,20 +246,17 @@ def _send_to_subscription(subscription, title, body, icon, url, reference, tag):
|
|||||||
'reference': reference or {},
|
'reference': reference or {},
|
||||||
}
|
}
|
||||||
|
|
||||||
# If using Firebase Cloud Messaging
|
|
||||||
if FCM_API_KEY and subscription.get('Endpoint', '').startswith('https://fcm.'):
|
if FCM_API_KEY and subscription.get('Endpoint', '').startswith('https://fcm.'):
|
||||||
return _send_fcm_notification(subscription, payload)
|
return _send_fcm_notification(subscription, payload)
|
||||||
|
|
||||||
# Otherwise use standard Web Push Protocol
|
|
||||||
return _send_web_push_notification(subscription, payload)
|
return _send_web_push_notification(subscription, payload)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Error sending to subscription {subscription.get("_id")}: {e}')
|
logger.error(f'Error sending to subscription: {e}')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _send_fcm_notification(subscription, payload):
|
def _send_fcm_notification(subscription, payload):
|
||||||
"""Send notification via Firebase Cloud Messaging"""
|
|
||||||
try:
|
try:
|
||||||
if not FCM_API_KEY:
|
if not FCM_API_KEY:
|
||||||
logger.warning('FCM_API_KEY not configured')
|
logger.warning('FCM_API_KEY not configured')
|
||||||
@@ -311,9 +298,7 @@ def _send_fcm_notification(subscription, payload):
|
|||||||
|
|
||||||
|
|
||||||
def _send_web_push_notification(subscription, payload):
|
def _send_web_push_notification(subscription, payload):
|
||||||
"""Send notification using standard Web Push Protocol"""
|
|
||||||
try:
|
try:
|
||||||
# This requires pywebpush library
|
|
||||||
from pywebpush import webpush
|
from pywebpush import webpush
|
||||||
|
|
||||||
webpush(
|
webpush(
|
||||||
@@ -325,13 +310,13 @@ def _send_web_push_notification(subscription, payload):
|
|||||||
vapid_private_key=VAPID_PRIVATE_KEY,
|
vapid_private_key=VAPID_PRIVATE_KEY,
|
||||||
vapid_claims={'sub': VAPID_SUBJECT},
|
vapid_claims={'sub': VAPID_SUBJECT},
|
||||||
timeout=10,
|
timeout=10,
|
||||||
ttl=3600 # Notification expires after 1 hour if device is offline
|
ttl=3600
|
||||||
)
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning('pywebpush not installed, install with: pip install pywebpush')
|
logger.warning('pywebpush not installed. pip install pywebpush')
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Web push error: {e}')
|
logger.error(f'Web push error: {e}')
|
||||||
@@ -339,7 +324,6 @@ def _send_web_push_notification(subscription, payload):
|
|||||||
|
|
||||||
|
|
||||||
def _mark_subscription_inactive(subscription_id):
|
def _mark_subscription_inactive(subscription_id):
|
||||||
"""Mark a subscription as inactive (e.g., after failed send)"""
|
|
||||||
try:
|
try:
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = client[cfg.MONGODB_DB]
|
db = client[cfg.MONGODB_DB]
|
||||||
@@ -349,37 +333,21 @@ def _mark_subscription_inactive(subscription_id):
|
|||||||
{'_id': ObjectId(subscription_id)},
|
{'_id': ObjectId(subscription_id)},
|
||||||
{'$set': {'IsActive': False}}
|
{'$set': {'IsActive': False}}
|
||||||
)
|
)
|
||||||
|
|
||||||
client.close()
|
client.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Error marking subscription inactive: {e}')
|
logger.error(f'Error marking subscription inactive: {e}')
|
||||||
|
|
||||||
|
|
||||||
def send_push_to_all_admins(title, body, icon=None, url='/', reference=None):
|
def send_push_to_all_admins(title, body, icon=None, url='/', reference=None):
|
||||||
"""
|
|
||||||
Send a push notification to all admin users
|
|
||||||
|
|
||||||
Args:
|
|
||||||
title (str): Notification title
|
|
||||||
body (str): Notification body
|
|
||||||
icon (str, optional): Icon URL
|
|
||||||
url (str, optional): URL to open on click
|
|
||||||
reference (dict, optional): Reference data
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int: Total notifications sent
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = client[cfg.MONGODB_DB]
|
db = client[cfg.MONGODB_DB]
|
||||||
users_col = db['users']
|
users_col = db['users']
|
||||||
|
|
||||||
# Get all admin users
|
|
||||||
admin_users = list(users_col.find(
|
admin_users = list(users_col.find(
|
||||||
{'Admin': True},
|
{'Admin': True},
|
||||||
{'Username': 1}
|
{'Username': 1}
|
||||||
))
|
))
|
||||||
|
|
||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
total_sent = 0
|
total_sent = 0
|
||||||
@@ -404,10 +372,6 @@ def send_push_to_all_admins(title, body, icon=None, url='/', reference=None):
|
|||||||
|
|
||||||
|
|
||||||
def cleanup_inactive_subscriptions():
|
def cleanup_inactive_subscriptions():
|
||||||
"""
|
|
||||||
Remove inactive subscriptions older than 30 days
|
|
||||||
Run this periodically as a maintenance task
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = client[cfg.MONGODB_DB]
|
db = client[cfg.MONGODB_DB]
|
||||||
@@ -429,22 +393,19 @@ def cleanup_inactive_subscriptions():
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
# Database collection schema
|
|
||||||
def ensure_push_subscriptions_collection():
|
def ensure_push_subscriptions_collection():
|
||||||
"""Ensure the push_subscriptions collection exists with proper indexes"""
|
|
||||||
try:
|
try:
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = client[cfg.MONGODB_DB]
|
db = client[cfg.MONGODB_DB]
|
||||||
subs_col = get_push_subscriptions_collection(db)
|
subs_col = get_push_subscriptions_collection(db)
|
||||||
|
|
||||||
# Create indexes
|
subs_col.create_index('UsernameHash')
|
||||||
subs_col.create_index('Username')
|
subs_col.create_index([('UsernameHash', 1), ('IsActive', 1)])
|
||||||
subs_col.create_index([('Username', 1), ('IsActive', 1)])
|
subs_col.create_index([('CreatedAt', 1)])
|
||||||
subs_col.create_index([('CreatedAt', 1)]) # TTL-like usage
|
|
||||||
subs_col.create_index('SubscriptionHash', unique=True)
|
subs_col.create_index('SubscriptionHash', unique=True)
|
||||||
|
|
||||||
logger.info('Push subscriptions collection indexes created')
|
logger.info('Push subscriptions collection indexes created')
|
||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Error ensuring push subscriptions collection: {e}')
|
logger.error(f'Error ensuring push subscriptions collection: {e}')
|
||||||
@@ -1144,6 +1144,17 @@
|
|||||||
<li class="nav-item dropdown ms-lg-auto">
|
<li class="nav-item dropdown ms-lg-auto">
|
||||||
<a class="nav-link dropdown-toggle" href="#" id="termMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">Mehr Optionen</a>
|
<a class="nav-link dropdown-toggle" href="#" id="termMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">Mehr Optionen</a>
|
||||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="termMoreDropdown">
|
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="termMoreDropdown">
|
||||||
|
{% if 'username' in session %}
|
||||||
|
{% if current_permissions.pages.get('tutorial_page', True) %}
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
|
||||||
|
{% endif %}
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('admin_school_settings') }}">Schulstammdaten</a></li>
|
||||||
|
{% if current_permissions.actions.get('can_view_logs', True) and current_permissions.pages.get('admin_audit_dashboard', True) %}
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('admin_audit_dashboard') }}">Audit Dashboard</a></li>
|
||||||
|
{% endif %}
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if current_permissions.pages.get('home', True) %}
|
{% if current_permissions.pages.get('home', True) %}
|
||||||
<li><a class="dropdown-item" href="{{ url_for('home') }}">Inventarsystem</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('home') }}">Inventarsystem</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -1155,6 +1166,42 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<div class="d-flex">
|
||||||
|
{% if 'username' in session %}
|
||||||
|
<div class="function-search-wrap">
|
||||||
|
<form class="function-search-form" data-function-search="true">
|
||||||
|
<input
|
||||||
|
class="function-search-input"
|
||||||
|
type="search"
|
||||||
|
name="function_search"
|
||||||
|
placeholder="Funktion suchen..."
|
||||||
|
list="function-search-options"
|
||||||
|
autocomplete="off"
|
||||||
|
>
|
||||||
|
<button class="function-search-btn" type="submit">Los</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% if current_tenant_db %}
|
||||||
|
<span class="navbar-text tenant-badge" title="Aktive Tenant-Datenbank">{{ current_tenant_db }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="navbar-text text-light me-3">{{ session['username'] }}</span>
|
||||||
|
<div class="dropdown me-2 user-menu-wrap">
|
||||||
|
<button class="btn btn-secondary dropdown-toggle user-menu-btn" type="button" id="invUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false" data-notification-button="true">
|
||||||
|
👤
|
||||||
|
<span class="user-notification-dot {% if unread_notification_count and unread_notification_count > 0 %}visible{% endif %}" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invUserMenuDropdown">
|
||||||
|
{% if current_permissions.pages.get('notifications_view', True) %}
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('notifications_view') }}">Benachrichtigungen</a></li>
|
||||||
|
{% endif %}
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1057,7 +1057,7 @@
|
|||||||
document.getElementById('detailModal').style.display = 'none';
|
document.getElementById('detailModal').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// 5. EVENT LISTENERS INITIALIZATION & ON-LOAD INITIALIZER
|
// 5. EVENT LISTENERS INITIALIZATION & ON-LOAD INITIALIZER
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
function wireScannerUi() {
|
function wireScannerUi() {
|
||||||
@@ -1192,7 +1192,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Modal Control Functions (accessible globally from table buttons)
|
|
||||||
function openEditLibraryItem(itemId) {
|
function openEditLibraryItem(itemId) {
|
||||||
const item = libraryItems.find(i => i._id === itemId);
|
const item = libraryItems.find(i => i._id === itemId);
|
||||||
|
|
||||||
@@ -1201,7 +1200,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Populate the form fields - Using correct German properties from MongoDB fields
|
// Felder befüllen
|
||||||
document.getElementById('editLibraryItemId').value = item._id || '';
|
document.getElementById('editLibraryItemId').value = item._id || '';
|
||||||
document.getElementById('editLibraryName').value = item.Name || '';
|
document.getElementById('editLibraryName').value = item.Name || '';
|
||||||
document.getElementById('editLibraryType').value = item.ItemType || 'book';
|
document.getElementById('editLibraryType').value = item.ItemType || 'book';
|
||||||
@@ -1210,18 +1209,110 @@
|
|||||||
document.getElementById('editLibraryLocation').value = item.Ort || '';
|
document.getElementById('editLibraryLocation').value = item.Ort || '';
|
||||||
document.getElementById('editLibraryDescription').value = item.Beschreibung || '';
|
document.getElementById('editLibraryDescription').value = item.Beschreibung || '';
|
||||||
|
|
||||||
|
// Gruppen-Info-Box Logik
|
||||||
|
const warningDiv = document.getElementById('editLibraryGroupWarning');
|
||||||
|
const codesContainer = document.getElementById('editLibraryAllCodes');
|
||||||
|
|
||||||
|
if (item.SeriesGroupId) {
|
||||||
|
const groupMembers = libraryItems
|
||||||
|
.filter(i => i.SeriesGroupId === item.SeriesGroupId)
|
||||||
|
.sort((a, b) => a.SeriesPosition - b.SeriesPosition);
|
||||||
|
|
||||||
|
document.getElementById('editLibraryGroupCount').textContent = groupMembers.length;
|
||||||
|
|
||||||
|
codesContainer.innerHTML = groupMembers.map(member => {
|
||||||
|
const isCurrent = member._id === itemId;
|
||||||
|
return `<span style="background: ${isCurrent ? '#0ea5e9' : '#e0e0e0'}; color: ${isCurrent ? '#fff' : '#333'}; padding: 2px 8px; border-radius: 4px; margin-right: 5px; font-size: 12px;">${member.Code_4 || 'n/a'}</span>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
warningDiv.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
warningDiv.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('editLibraryModal').style.display = 'flex';
|
document.getElementById('editLibraryModal').style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeEditLibraryModal() {
|
function closeEditLibraryModal() {
|
||||||
document.getElementById('editLibraryModal').style.display = 'none';
|
document.getElementById('editLibraryModal').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Sicherer Event-Listener für das Formular ---
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const editForm = document.getElementById('editLibraryForm');
|
||||||
|
|
||||||
|
if (editForm) {
|
||||||
|
editForm.addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const itemId = document.getElementById('editLibraryItemId').value;
|
||||||
|
const updateData = {
|
||||||
|
id: itemId,
|
||||||
|
name: document.getElementById('editLibraryName').value,
|
||||||
|
item_type: document.getElementById('editLibraryType').value,
|
||||||
|
isbn: document.getElementById('editLibraryIsbn').value,
|
||||||
|
code_4: document.getElementById('editLibraryCode4').value,
|
||||||
|
ort: document.getElementById('editLibraryLocation').value,
|
||||||
|
beschreibung: document.getElementById('editLibraryDescription').value
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/update-item', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updateData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const editedItem = libraryItems.find(i => i._id === itemId);
|
||||||
|
|
||||||
|
// Lokale Daten im Array synchronisieren
|
||||||
|
if (editedItem && editedItem.SeriesGroupId) {
|
||||||
|
libraryItems.forEach(item => {
|
||||||
|
if (item.SeriesGroupId === editedItem.SeriesGroupId) {
|
||||||
|
item.Name = updateData.name;
|
||||||
|
item.ItemType = updateData.item_type;
|
||||||
|
item.ISBN = updateData.isbn;
|
||||||
|
item.Ort = updateData.ort;
|
||||||
|
item.Beschreibung = updateData.beschreibung;
|
||||||
|
|
||||||
|
if (item._id === itemId) item.Code_4 = updateData.code_4;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (editedItem) {
|
||||||
|
Object.assign(editedItem, updateData);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeEditLibraryModal();
|
||||||
|
// Optional: renderLibraryTable();
|
||||||
|
} else {
|
||||||
|
const err = await response.json();
|
||||||
|
alert('Fehler beim Speichern: ' + (err.error || 'Unbekannter Fehler'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch error:', error);
|
||||||
|
alert('Netzwerkfehler.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div id="editLibraryModal" class="modal" style="display:none;">
|
<div id="editLibraryModal" class="modal" style="display:none;">
|
||||||
<div class="modal-content" style="max-width: 760px;">
|
<div class="modal-content" style="max-width: 760px;">
|
||||||
<span class="close" onclick="closeEditLibraryModal()">×</span>
|
<span class="close" onclick="closeEditLibraryModal()">×</span>
|
||||||
<h3 style="margin-top:0;">Bibliotheksmedium bearbeiten</h3>
|
<h3 style="margin-top:0;">Bibliotheksmedium bearbeiten</h3>
|
||||||
|
|
||||||
|
<!-- Bereich für Gruppen-Informationen -->
|
||||||
|
<div id="editLibraryGroupWarning" style="display:none; background-color: #f8f9fa; padding: 15px; border-radius: 6px; margin-bottom: 20px; border: 1px solid #dee2e6;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||||
|
<strong style="color: #0ea5e9;">Gruppen-Synchronisierung aktiv</strong>
|
||||||
|
<span style="font-size: 12px; color: #666;">Gesamt: <span id="editLibraryGroupCount"></span> Exemplare</span>
|
||||||
|
</div>
|
||||||
|
<p style="margin: 0 0 10px 0; font-size: 13px;">Codes in dieser Range:</p>
|
||||||
|
<div id="editLibraryAllCodes" style="display: flex; flex-wrap: wrap; gap: 5px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form id="editLibraryForm">
|
<form id="editLibraryForm">
|
||||||
<input type="hidden" id="editLibraryItemId">
|
<input type="hidden" id="editLibraryItemId">
|
||||||
<div class="edit-grid">
|
<div class="edit-grid">
|
||||||
@@ -1256,7 +1347,7 @@
|
|||||||
<textarea id="editLibraryDescription" rows="4" required></textarea>
|
<textarea id="editLibraryDescription" rows="4" required></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="edit-actions">
|
<div class="edit-actions" style="margin-top:20px;">
|
||||||
<button type="submit" class="button" style="background:#0ea5e9;color:#fff;">Speichern</button>
|
<button type="submit" class="button" style="background:#0ea5e9;color:#fff;">Speichern</button>
|
||||||
<button type="button" class="button" onclick="closeEditLibraryModal()">Abbrechen</button>
|
<button type="button" class="button" onclick="closeEditLibraryModal()">Abbrechen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -880,6 +880,193 @@ PY
|
|||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
|
|
||||||
|
backup)
|
||||||
|
TENANT_ID="${2:-}"
|
||||||
|
if [ -z "$TENANT_ID" ]; then
|
||||||
|
echo "Error: Please provide a tenant_id."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
OUTPUT_FILE="${3:-backup_${TENANT_ID}_$(date +%Y%m%d_%H%M%S).zip}"
|
||||||
|
|
||||||
|
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||||
|
if [ -z "$APP_CONTAINER" ]; then
|
||||||
|
echo "Error: Application container not running."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Creating complete backup (.zip) for tenant '$TENANT_ID'..."
|
||||||
|
|
||||||
|
# We generate the zip temporarily inside the container
|
||||||
|
CONTAINER_TMP_ZIP="/tmp/backup_${TENANT_ID}_$(date +%s).zip"
|
||||||
|
|
||||||
|
docker exec "$APP_CONTAINER" python3 - "$TENANT_ID" "$CONTAINER_TMP_ZIP" <<'PY'
|
||||||
|
import sys, os, zipfile
|
||||||
|
from bson import json_util
|
||||||
|
sys.path.insert(0, '/app')
|
||||||
|
sys.path.insert(0, '/app/Web')
|
||||||
|
from Web.modules.database import settings
|
||||||
|
from pymongo import MongoClient
|
||||||
|
|
||||||
|
tenant_id = sys.argv[1]
|
||||||
|
zip_path = sys.argv[2]
|
||||||
|
|
||||||
|
# CHANGE THIS if your app stores files in a different directory
|
||||||
|
UPLOADS_BASE = f"/app/uploads/{tenant_id}"
|
||||||
|
|
||||||
|
sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_")
|
||||||
|
db_name = f"inventar_{sanitized}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||||
|
db = client[db_name]
|
||||||
|
|
||||||
|
dump = {}
|
||||||
|
for coll_name in db.list_collection_names():
|
||||||
|
if coll_name.startswith('system.'):
|
||||||
|
continue
|
||||||
|
dump[coll_name] = list(db[coll_name].find())
|
||||||
|
|
||||||
|
db_json = json_util.dumps(dump)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
# 1. Write the database dump
|
||||||
|
zf.writestr('database.json', db_json)
|
||||||
|
|
||||||
|
# 2. Add uploaded photos/invoices if the directory exists
|
||||||
|
if os.path.isdir(UPLOADS_BASE):
|
||||||
|
for root, dirs, files in os.walk(UPLOADS_BASE):
|
||||||
|
for file in files:
|
||||||
|
file_path = os.path.join(root, file)
|
||||||
|
# Create a relative path for the zip internal structure
|
||||||
|
arcname = os.path.relpath(file_path, start=UPLOADS_BASE)
|
||||||
|
zf.write(file_path, arcname=f"uploads/{arcname}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error creating backup: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
PY
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
# Copy the zip out of the container to the host
|
||||||
|
docker cp "$APP_CONTAINER:$CONTAINER_TMP_ZIP" "$OUTPUT_FILE"
|
||||||
|
# Cleanup inside the container
|
||||||
|
docker exec "$APP_CONTAINER" rm -f "$CONTAINER_TMP_ZIP"
|
||||||
|
echo "Backup successfully created: $OUTPUT_FILE"
|
||||||
|
else
|
||||||
|
echo "Error: Backup failed."
|
||||||
|
docker exec "$APP_CONTAINER" rm -f "$CONTAINER_TMP_ZIP"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
restore)
|
||||||
|
TENANT_ID="${2:-}"
|
||||||
|
INPUT_FILE="${3:-}"
|
||||||
|
|
||||||
|
if [ -z "$TENANT_ID" ] || [ -z "$INPUT_FILE" ]; then
|
||||||
|
echo "Error: Usage: $0 restore <tenant_id> <backup_file.zip>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$INPUT_FILE" ]; then
|
||||||
|
echo "Error: Backup file '$INPUT_FILE' not found."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -n "WARNING: This will DROP the existing database and completely overwrite data AND files for tenant '$TENANT_ID'. Are you sure? (y/N) "
|
||||||
|
read confirm
|
||||||
|
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
|
||||||
|
echo "Restore canceled."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||||
|
if [ -z "$APP_CONTAINER" ]; then
|
||||||
|
echo "Error: Application container not running."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Uploading backup archive to container..."
|
||||||
|
CONTAINER_TMP_ZIP="/tmp/restore_${TENANT_ID}_$(date +%s).zip"
|
||||||
|
docker cp "$INPUT_FILE" "$APP_CONTAINER:$CONTAINER_TMP_ZIP"
|
||||||
|
|
||||||
|
echo "Restoring database and files for tenant '$TENANT_ID'..."
|
||||||
|
|
||||||
|
docker exec "$APP_CONTAINER" python3 - "$TENANT_ID" "$CONTAINER_TMP_ZIP" <<'PY'
|
||||||
|
import sys, os, zipfile, shutil
|
||||||
|
from bson import json_util
|
||||||
|
sys.path.insert(0, '/app')
|
||||||
|
sys.path.insert(0, '/app/Web')
|
||||||
|
from Web.modules.database import settings
|
||||||
|
from pymongo import MongoClient
|
||||||
|
|
||||||
|
tenant_id = sys.argv[1]
|
||||||
|
zip_path = sys.argv[2]
|
||||||
|
|
||||||
|
# CHANGE THIS if your app stores files in a different directory
|
||||||
|
UPLOADS_BASE = f"/app/uploads/{tenant_id}"
|
||||||
|
|
||||||
|
sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_")
|
||||||
|
db_name = f"inventar_{sanitized}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(zip_path, 'r') as zf:
|
||||||
|
# 1. Restore Database
|
||||||
|
db_json = zf.read('database.json').decode('utf-8')
|
||||||
|
data = json_util.loads(db_json)
|
||||||
|
|
||||||
|
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||||
|
client.drop_database(db_name)
|
||||||
|
db = client[db_name]
|
||||||
|
|
||||||
|
restored_count = 0
|
||||||
|
for coll_name, docs in data.items():
|
||||||
|
if docs:
|
||||||
|
db[coll_name].insert_many(docs)
|
||||||
|
restored_count += len(docs)
|
||||||
|
|
||||||
|
# 2. Restore Files
|
||||||
|
# Wipe existing uploads to ensure an exact replica of the backup
|
||||||
|
if os.path.exists(UPLOADS_BASE):
|
||||||
|
shutil.rmtree(UPLOADS_BASE)
|
||||||
|
|
||||||
|
for item in zf.namelist():
|
||||||
|
# Extract only file items that were saved under 'uploads/'
|
||||||
|
if item.startswith('uploads/') and not item.endswith('/'):
|
||||||
|
rel_path = item[len('uploads/'):]
|
||||||
|
target_path = os.path.join(UPLOADS_BASE, rel_path)
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||||
|
with open(target_path, 'wb') as f:
|
||||||
|
f.write(zf.read(item))
|
||||||
|
|
||||||
|
print(f"Successfully restored {restored_count} database documents and tenant files.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error restoring backup: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
PY
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
# Clean up tmp zip inside container
|
||||||
|
docker exec "$APP_CONTAINER" rm -f "$CONTAINER_TMP_ZIP"
|
||||||
|
echo "Restore completed successfully."
|
||||||
|
|
||||||
|
echo "Clearing session cache..."
|
||||||
|
docker exec "$APP_CONTAINER" python3 -c "
|
||||||
|
import sys; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from Web.modules.database import settings; from pymongo import MongoClient
|
||||||
|
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||||
|
db = client[f'inventar_{"".join(c for c in sys.argv[1] if c.isalnum() or c == "_")}']
|
||||||
|
db.sessions.drop()
|
||||||
|
" "$TENANT_ID"
|
||||||
|
|
||||||
|
else
|
||||||
|
echo "Error: Restore failed."
|
||||||
|
docker exec "$APP_CONTAINER" rm -f "$CONTAINER_TMP_ZIP"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
*)
|
*)
|
||||||
echo "Unknown command: $COMMAND"
|
echo "Unknown command: $COMMAND"
|
||||||
show_help
|
show_help
|
||||||
|
|||||||
Reference in New Issue
Block a user