Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6583f7a368 | |||
| 8e11fbf969 | |||
| 7d13dc264c | |||
| 480bd00a54 | |||
| fdf699363a | |||
| dd495a8b13 | |||
| ca57961d4b | |||
| 3923939387 | |||
| eb9c655398 | |||
| 4c2657218d | |||
| 875cfa01a3 | |||
| c0c61a41a3 | |||
| 9e74d0c16d | |||
| c8818edf0b | |||
| b270bc9367 | |||
| e1c284fc40 | |||
| debd4dceb1 | |||
| db15df57c3 | |||
| bcfe2095d5 | |||
| 69ac6eca63 | |||
| 717e3ce15e | |||
| 007ae04bf3 | |||
| c0947c5cf1 | |||
| c1d2935ad6 | |||
| 9fde0b4b2a | |||
| da7b075cc4 | |||
| 347f258b8f | |||
| 2421f867ed | |||
| 921915e92f | |||
| a4b0866293 |
@@ -556,6 +556,14 @@ sudo chmod 644 certs/inventarsystem.crt
|
|||||||
|
|
||||||
## Fehlerbehebung
|
## Fehlerbehebung
|
||||||
|
|
||||||
|
### Logs Auslesen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs inventarsystem-app-1
|
||||||
|
docker exec inventarsystem-app-1 cat /data/logs/application.log | tail -n 100
|
||||||
|
docker compose exec mongodb mongosh
|
||||||
|
```
|
||||||
|
|
||||||
### Webserver startet nicht
|
### Webserver startet nicht
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+30
-22
@@ -731,6 +731,7 @@ AUDIT_INDEXES_READY = False
|
|||||||
|
|
||||||
def _ensure_audit_indexes_once():
|
def _ensure_audit_indexes_once():
|
||||||
"""Ensure audit indexes exist once per process."""
|
"""Ensure audit indexes exist once per process."""
|
||||||
|
global AUDIT_INDEXES_READY
|
||||||
if AUDIT_INDEXES_READY:
|
if AUDIT_INDEXES_READY:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -3301,7 +3302,13 @@ def api_library_items():
|
|||||||
borrower = doc.get('User', '')
|
borrower = doc.get('User', '')
|
||||||
# Decrypt if value is encrypted (decrypt_text returns original if not encrypted)
|
# Decrypt if value is encrypted (decrypt_text returns original if not encrypted)
|
||||||
try:
|
try:
|
||||||
borrower = decrypt_text(borrower) if borrower else ''
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
|
db = client[cfg.MONGODB_DB]
|
||||||
|
student_cards = db['student_cards']
|
||||||
|
card = student_cards.find_one({'_id': ObjectId(borrow_id)})
|
||||||
|
card = _decrypt_student_card_doc(card)
|
||||||
|
client.close()
|
||||||
|
borrower = card['SchülerName']
|
||||||
except Exception:
|
except Exception:
|
||||||
# Fallback: keep original string if decryption fails
|
# Fallback: keep original string if decryption fails
|
||||||
borrower = borrower or ''
|
borrower = borrower or ''
|
||||||
@@ -3820,7 +3827,7 @@ def library_admin():
|
|||||||
show_library_features=True,
|
show_library_features=True,
|
||||||
upload_mode='library',
|
upload_mode='library',
|
||||||
page_title='Bücher hochladen',
|
page_title='Bücher hochladen',
|
||||||
back_target='home_admin'
|
back_target='library'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -5073,7 +5080,7 @@ def upload_item():
|
|||||||
Enhanced for mobile browser compatibility.
|
Enhanced for mobile browser compatibility.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
flask.Response: Redirect to admin homepage or JSON response
|
flask.Response: Redirect to admin homepage
|
||||||
"""
|
"""
|
||||||
# Check if the user is authenticated
|
# Check if the user is authenticated
|
||||||
if 'username' not in session:
|
if 'username' not in session:
|
||||||
@@ -5086,7 +5093,12 @@ def upload_item():
|
|||||||
return jsonify({'success': False, 'message': 'Einfüge-Rechte erforderlich'}), 403
|
return jsonify({'success': False, 'message': 'Einfüge-Rechte erforderlich'}), 403
|
||||||
|
|
||||||
can_access_admin_home = _page_access_allowed(permissions, 'home_admin') and _action_access_allowed(permissions, 'can_manage_settings')
|
can_access_admin_home = _page_access_allowed(permissions, 'home_admin') and _action_access_allowed(permissions, 'can_manage_settings')
|
||||||
success_redirect_endpoint = 'home_admin' if can_access_admin_home else 'home'
|
if can_access_admin_home:
|
||||||
|
success_redirect_endpoint = 'home_admin'
|
||||||
|
elif cfg.MODULES.is_enabled('library') and _page_access_allowed(permissions, 'home_library'):
|
||||||
|
success_redirect_endpoint = 'home_library'
|
||||||
|
else:
|
||||||
|
success_redirect_endpoint = 'home'
|
||||||
|
|
||||||
# Detect if request is from mobile device
|
# Detect if request is from mobile device
|
||||||
is_mobile = 'Mobile' in request.headers.get('User-Agent', '')
|
is_mobile = 'Mobile' in request.headers.get('User-Agent', '')
|
||||||
@@ -6043,22 +6055,9 @@ def upload_item():
|
|||||||
except Exception:
|
except Exception:
|
||||||
app.logger.warning('Audit write failed for library_item_created')
|
app.logger.warning('Audit write failed for library_item_created')
|
||||||
|
|
||||||
if is_mobile:
|
|
||||||
return jsonify({
|
flash(success_msg, 'success')
|
||||||
'success': True,
|
return redirect(url_for(success_redirect_endpoint, highlight_item=str(item_id)))
|
||||||
'message': success_msg,
|
|
||||||
'itemId': str(item_id),
|
|
||||||
'stats': {
|
|
||||||
'processed': processed_count,
|
|
||||||
'errors': error_count,
|
|
||||||
'skipped': skipped_count,
|
|
||||||
'duplicates': len(duplicate_images) if duplicate_images else 0,
|
|
||||||
'totalImages': len(image_filenames)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
flash(success_msg, 'success')
|
|
||||||
return redirect(url_for(success_redirect_endpoint, highlight_item=str(item_id)))
|
|
||||||
else:
|
else:
|
||||||
error_msg = 'Fehler beim Hinzufügen des Elements'
|
error_msg = 'Fehler beim Hinzufügen des Elements'
|
||||||
if is_mobile:
|
if is_mobile:
|
||||||
@@ -8375,8 +8374,15 @@ def admin_create_invoice(borrow_id):
|
|||||||
flash('Für diese Ausleihe existiert bereits eine Rechnung. Bitte Korrekturbuchung verwenden.', 'warning')
|
flash('Für diese Ausleihe existiert bereits eine Rechnung. Bitte Korrekturbuchung verwenden.', 'warning')
|
||||||
return redirect(url_for('admin_borrowings'))
|
return redirect(url_for('admin_borrowings'))
|
||||||
|
|
||||||
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
|
db = client[cfg.MONGODB_DB]
|
||||||
|
student_cards = db['student_cards']
|
||||||
|
card = student_cards.find_one({'_id': ObjectId(borrow_id)})
|
||||||
|
card = _decrypt_student_card_doc(card)
|
||||||
|
client.close()
|
||||||
|
borrower = card['SchülerName']
|
||||||
|
|
||||||
invoice_number = existing_invoice.get('invoice_number') or _build_invoice_number(borrow_doc['_id'], now)
|
invoice_number = existing_invoice.get('invoice_number') or _build_invoice_number(borrow_doc['_id'], now)
|
||||||
borrower = borrow_doc.get('User', '')
|
|
||||||
item_name = item_doc.get('Name', '')
|
item_name = item_doc.get('Name', '')
|
||||||
item_code = item_doc.get('Code_4', '')
|
item_code = item_doc.get('Code_4', '')
|
||||||
|
|
||||||
@@ -10275,12 +10281,14 @@ def admin_damaged_items():
|
|||||||
flash('Administratorrechte erforderlich.', 'error')
|
flash('Administratorrechte erforderlich.', 'error')
|
||||||
return redirect(url_for('login'))
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
'''
|
||||||
permissions = _get_current_user_permissions()
|
permissions = _get_current_user_permissions()
|
||||||
if not _action_access_allowed(permissions, 'can_manage_settings'):
|
if not _action_access_allowed(permissions, 'can_manage_settings'):
|
||||||
flash('Sie haben keine Berechtigung, die Schulstammdaten zu ändern.', 'error')
|
flash('Sie haben keine Berechtigung, die Schulstammdaten zu ändern.', 'error')
|
||||||
if cfg.MODULES.is_enabled('library'):
|
if cfg.MODULES.is_enabled('library'):
|
||||||
return redirect(url_for('library_admin'))
|
return redirect(url_for('library_admin'))
|
||||||
|
'''
|
||||||
|
|
||||||
client = None
|
client = None
|
||||||
try:
|
try:
|
||||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||||
|
|||||||
@@ -284,7 +284,6 @@ def _match_inventory(path):
|
|||||||
if path == '/' or path.startswith('/home'): return True
|
if path == '/' or path.startswith('/home'): return True
|
||||||
return path.startswith(('/scanner', '/inventory', '/upload_admin', '/manage_filters', '/manage_locations', '/admin_borrowings', '/admin_damaged_items', '/admin/borrowings', '/admin/damaged_items'))
|
return path.startswith(('/scanner', '/inventory', '/upload_admin', '/manage_filters', '/manage_locations', '/admin_borrowings', '/admin_damaged_items', '/admin/borrowings', '/admin/damaged_items'))
|
||||||
|
|
||||||
|
|
||||||
def _match_terminplan(path):
|
def _match_terminplan(path):
|
||||||
if not path:
|
if not path:
|
||||||
return False
|
return False
|
||||||
@@ -292,7 +291,7 @@ def _match_terminplan(path):
|
|||||||
|
|
||||||
def _match_library(path):
|
def _match_library(path):
|
||||||
if not path: return False
|
if not path: return False
|
||||||
return path.startswith(('/library', '/library_', '/student_cards'))
|
return path.startswith(('/library', '/library_', '/student_cards', '/admin_damaged_items', '/admin_borrowings', '/admin/library'))
|
||||||
|
|
||||||
def _match_student_cards(path):
|
def _match_student_cards(path):
|
||||||
if not path: return False
|
if not path: return False
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ def _active_record_query(extra_query=None):
|
|||||||
return base_query
|
return base_query
|
||||||
|
|
||||||
|
|
||||||
def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False, title: str=""):
|
def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False, title: str="", custom_fields: list = ()):
|
||||||
try:
|
try:
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = _get_tenant_db(client)
|
db = _get_tenant_db(client)
|
||||||
@@ -54,8 +54,9 @@ def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
|
|||||||
'mail': mail,
|
'mail': mail,
|
||||||
'note': note,
|
'note': note,
|
||||||
'title': title,
|
'title': title,
|
||||||
|
'custom_fields': custom_fields,
|
||||||
'calendar_enabled': bool(calendar_enabled),
|
'calendar_enabled': bool(calendar_enabled),
|
||||||
'slots_booked': [], # -> [(start_time, name), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable
|
'slots_booked': [], # -> [(start_time, name,(custom1, custom2,...)), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable
|
||||||
'Created': datetime.datetime.now(),
|
'Created': datetime.datetime.now(),
|
||||||
'LastUpdated': datetime.datetime.now()
|
'LastUpdated': datetime.datetime.now()
|
||||||
}
|
}
|
||||||
|
|||||||
+90
-106
@@ -20,6 +20,8 @@ import string
|
|||||||
from bson.objectid import ObjectId
|
from bson.objectid import ObjectId
|
||||||
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
|
||||||
|
import hmac
|
||||||
|
import os
|
||||||
|
|
||||||
logger = logging.getLogger('app')
|
logger = logging.getLogger('app')
|
||||||
logger.setLevel(logging.DEBUG)
|
logger.setLevel(logging.DEBUG)
|
||||||
@@ -430,104 +432,84 @@ def check_password_strength(password):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def hashing(password):
|
def hashing(password, salt=None):
|
||||||
"""
|
"""
|
||||||
Hash a password using scrypt.
|
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>
|
||||||
|
"""
|
||||||
|
password_bytes = password.encode('utf-8') # Explizit UTF-8 für Plattformunabhängigkeit
|
||||||
|
|
||||||
Args:
|
if salt is None:
|
||||||
password (str): Password to hash
|
# Neuer Benutzer / Passwortänderung -> Dynamischer Salt
|
||||||
|
random_salt = os.urandom(16)
|
||||||
Returns:
|
hashed = hashlib.scrypt(password_bytes, salt=random_salt, n=16384, r=8, p=1)
|
||||||
str: Hexadecimal digest of the hashed password
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(provided_password, stored_password_string):
|
||||||
"""
|
"""
|
||||||
hashed = hashlib.scrypt(password.encode(), salt=b'some_salt', n=16384, r=8, p=1)
|
Verifiziert ein Passwort gegen einen gespeicherten Hash-String.
|
||||||
return hashed.hex()
|
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)
|
||||||
|
|
||||||
|
|
||||||
def check_nm_pwd(username, password):
|
def check_nm_pwd(username, password):
|
||||||
"""
|
"""
|
||||||
Verify username and password combination.
|
Überprüft die Kombination aus Benutzername und Passwort (optimiert).
|
||||||
|
|
||||||
Args:
|
|
||||||
username (str): Username to check
|
|
||||||
password (str): Password to verify
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: User document if credentials are valid, None otherwise
|
|
||||||
"""
|
"""
|
||||||
db_name, tenant_id = _resolve_request_tenant_db()
|
db_name, tenant_id = _resolve_request_tenant_db()
|
||||||
ctx = None
|
|
||||||
try:
|
|
||||||
from tenant import get_tenant_context
|
|
||||||
ctx = get_tenant_context()
|
|
||||||
except Exception:
|
|
||||||
ctx = None
|
|
||||||
|
|
||||||
if not db_name:
|
if not db_name:
|
||||||
if _has_tenant_configs():
|
if _has_tenant_configs():
|
||||||
logger.warning(
|
logger.warning("Default DB fallback verweigert, da Tenant-Konfigurationen existieren.")
|
||||||
"Refusing default DB fallback for login because tenant configs exist and no tenant was resolved."
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
db_name = cfg.MONGODB_DB
|
db_name = cfg.MONGODB_DB
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"check_nm_pwd start: username=%r tenant=%r db=%r host=%r port=%r uri=%r",
|
|
||||||
username,
|
|
||||||
tenant_id,
|
|
||||||
db_name,
|
|
||||||
cfg.MONGODB_HOST,
|
|
||||||
cfg.MONGODB_PORT,
|
|
||||||
getattr(cfg, 'MONGODB_URI', None),
|
|
||||||
)
|
|
||||||
|
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
try:
|
try:
|
||||||
hashed_password = hashing(password)
|
|
||||||
logger.info("check_nm_pwd password hash for username=%r: %s", username, hashed_password)
|
|
||||||
available_dbs = []
|
|
||||||
try:
|
|
||||||
available_dbs = client.list_database_names()
|
|
||||||
logger.debug("MongoDB connected. Available databases=%s", available_dbs)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("Unable to list MongoDB databases: %s", exc)
|
|
||||||
|
|
||||||
db = client[db_name]
|
db = client[db_name]
|
||||||
try:
|
|
||||||
existing_collections = db.list_collection_names()
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("Unable to list collections for db=%r: %s", db_name, exc)
|
|
||||||
existing_collections = []
|
|
||||||
logger.debug("Tenant db=%r collections=%s", db_name, existing_collections)
|
|
||||||
|
|
||||||
users = db['users']
|
users = db['users']
|
||||||
|
|
||||||
query = {'$or': [{'Username': username}, {'username': username}]}
|
query = {'$or': [{'Username': username}, {'username': username}]}
|
||||||
logger.debug("Running user lookup on %r: %s", db_name, query)
|
|
||||||
user_record = users.find_one(query)
|
user_record = users.find_one(query)
|
||||||
|
|
||||||
if user_record is None:
|
if user_record is None:
|
||||||
logger.warning("No user document found in db=%r for username=%r", db_name, username)
|
logger.warning("Kein Benutzer für %r in DB %r gefunden.", username, db_name)
|
||||||
if db_name not in available_dbs:
|
|
||||||
logger.warning("Tenant database %r is missing from available MongoDB databases", db_name)
|
|
||||||
if 'users' not in existing_collections:
|
|
||||||
logger.warning("Tenant database %r has no users collection", db_name)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
logger.info("Found user document for username=%r in db=%r: %s", username, db_name, user_record)
|
|
||||||
stored_password = user_record.get('Password') or user_record.get('password')
|
stored_password = user_record.get('Password') or user_record.get('password')
|
||||||
if stored_password is None:
|
|
||||||
logger.warning("User document for username=%r in db=%r has no password field", username, db_name)
|
if not verify_password(password, stored_password):
|
||||||
|
logger.warning("Falsches Passwort für Benutzer %r in DB %r.", username, db_name)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if stored_password != hashed_password:
|
# Automatische Migration alter Hashes auf das neue Format
|
||||||
logger.warning(
|
if not stored_password.startswith("v1$"):
|
||||||
"Password mismatch for username=%r in db=%r: provided_hash=%s stored_hash=%s",
|
users.update_one({'_id': user_record['_id']}, {'$set': {'Password': hashing(password)}})
|
||||||
username,
|
|
||||||
db_name,
|
|
||||||
hashed_password,
|
|
||||||
stored_password,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
return user_record
|
return user_record
|
||||||
finally:
|
finally:
|
||||||
@@ -557,44 +539,46 @@ def add_user(
|
|||||||
bool: True if user was added successfully, False if password was too weak
|
bool: True if user was added successfully, False if password was too weak
|
||||||
"""
|
"""
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = _get_tenant_db(client)
|
try:
|
||||||
users = db['users']
|
db = _get_tenant_db(client)
|
||||||
if not check_password_strength(password):
|
users = db['users']
|
||||||
return False
|
if not check_password_strength(password):
|
||||||
permission_defaults = build_default_permission_payload(permission_preset)
|
return False
|
||||||
if isinstance(action_permissions, dict):
|
permission_defaults = build_default_permission_payload(permission_preset)
|
||||||
for key, value in action_permissions.items():
|
if isinstance(action_permissions, dict):
|
||||||
permission_defaults['actions'][str(key)] = bool(value)
|
for key, value in action_permissions.items():
|
||||||
if isinstance(page_permissions, dict):
|
permission_defaults['actions'][str(key)] = bool(value)
|
||||||
for key, value in page_permissions.items():
|
if isinstance(page_permissions, dict):
|
||||||
permission_defaults['pages'][str(key)] = bool(value)
|
for key, value in page_permissions.items():
|
||||||
|
permission_defaults['pages'][str(key)] = bool(value)
|
||||||
|
|
||||||
user_doc = {
|
user_doc = {
|
||||||
'Username': username,
|
'Username': username,
|
||||||
'Password': hashing(password),
|
'Password': hashing(password),
|
||||||
'Admin': False,
|
'Admin': False,
|
||||||
'active_ausleihung': None,
|
'active_ausleihung': None,
|
||||||
'name': name.strip() if name else '',
|
'name': name.strip() if name else '',
|
||||||
'last_name': last_name.strip() if last_name else '',
|
'last_name': last_name.strip() if last_name else '',
|
||||||
'IsStudent': bool(is_student),
|
'IsStudent': bool(is_student),
|
||||||
'PermissionPreset': permission_defaults['preset'],
|
'PermissionPreset': permission_defaults['preset'],
|
||||||
'ActionPermissions': permission_defaults['actions'],
|
'ActionPermissions': permission_defaults['actions'],
|
||||||
'PagePermissions': permission_defaults['pages'],
|
'PagePermissions': permission_defaults['pages'],
|
||||||
}
|
}
|
||||||
|
|
||||||
normalized_card = normalize_student_card_id(student_card_id)
|
normalized_card = normalize_student_card_id(student_card_id)
|
||||||
if bool(is_student):
|
if bool(is_student):
|
||||||
if normalized_card:
|
if normalized_card:
|
||||||
user_doc['StudentCardId'] = normalized_card
|
user_doc['StudentCardId'] = normalized_card
|
||||||
if max_borrow_days is not None:
|
if max_borrow_days is not None:
|
||||||
try:
|
try:
|
||||||
user_doc['MaxBorrowDays'] = int(max_borrow_days)
|
user_doc['MaxBorrowDays'] = int(max_borrow_days)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
users.insert_one(user_doc)
|
users.insert_one(user_doc)
|
||||||
client.close()
|
return True
|
||||||
return True
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
def student_card_exists(student_card_id):
|
def student_card_exists(student_card_id):
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str
|
|||||||
return '\r\n'.join(ics_lines)
|
return '\r\n'.join(ics_lines)
|
||||||
|
|
||||||
|
|
||||||
def new(date_start: str, date_end: str, time_span: list, slots, slot_length, user: str, mail: list=None, note:str="", calendar_enabled: bool=False, title: str="") -> dict:
|
def new(date_start: str, date_end: str, time_span: list, slots, slot_length, user: str, mail: list=None, note:str="", calendar_enabled: bool=False, title: str="", custom_fields: list = ()) -> dict:
|
||||||
"""
|
"""
|
||||||
Generates a link for the executive to send to his clients to book a time Slot
|
Generates a link for the executive to send to his clients to book a time Slot
|
||||||
"""
|
"""
|
||||||
@@ -207,6 +207,11 @@ def new(date_start: str, date_end: str, time_span: list, slots, slot_length, use
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
slots_int = 0
|
slots_int = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
custom_fields.pop(-1)
|
||||||
|
except :
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
slot_length_int = int(slot_length)
|
slot_length_int = int(slot_length)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
@@ -215,7 +220,7 @@ def new(date_start: str, date_end: str, time_span: list, slots, slot_length, use
|
|||||||
normalized_time_span = _normalize_time_span(time_span)
|
normalized_time_span = _normalize_time_span(time_span)
|
||||||
normalized_mail = _normalize_mail_list(mail or [])
|
normalized_mail = _normalize_mail_list(mail or [])
|
||||||
|
|
||||||
id = termin.add(date_start, date_end, normalized_time_span, slots_int, slot_length_int, user, normalized_mail, note, calendar_enabled=calendar_enabled, title=title)
|
id = termin.add(date_start, date_end, normalized_time_span, slots_int, slot_length_int, user, normalized_mail, note, calendar_enabled=calendar_enabled, title=title, custom_fields=custom_fields)
|
||||||
id_str = str(id)
|
id_str = str(id)
|
||||||
tenant_id = _current_tenant_id()
|
tenant_id = _current_tenant_id()
|
||||||
|
|
||||||
@@ -254,7 +259,7 @@ def new(date_start: str, date_end: str, time_span: list, slots, slot_length, use
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def book_slot(id, date_start_time, name):
|
def book_slot(id, date_start_time, name, custom:list=()):
|
||||||
try:
|
try:
|
||||||
item = termin.get_item(id)
|
item = termin.get_item(id)
|
||||||
if not item:
|
if not item:
|
||||||
@@ -273,7 +278,7 @@ def book_slot(id, date_start_time, name):
|
|||||||
if existing[0] == date_start_time and existing[1] == name:
|
if existing[0] == date_start_time and existing[1] == name:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
slots.append((date_start_time, name))
|
slots.append((date_start_time, name, custom))
|
||||||
success = termin.update(id, slots)
|
success = termin.update(id, slots)
|
||||||
return bool(success)
|
return bool(success)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -418,6 +423,7 @@ def get_user_upcoming_events(user: str, limit: int = 25) -> list[dict]:
|
|||||||
'link': link,
|
'link': link,
|
||||||
'calendar_link': calendar_link if item.get('calendar_enabled') else None,
|
'calendar_link': calendar_link if item.get('calendar_enabled') else None,
|
||||||
'title': str(item.get('title') or ''),
|
'title': str(item.get('title') or ''),
|
||||||
|
'custom_fields': list(item.get('custom_fields')),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import Web.modules.terminplaner.backend_server as appointment_service
|
|||||||
import Web.modules.database.settings as cfg
|
import Web.modules.database.settings as cfg
|
||||||
import Web.modules.database.termine as termin
|
import Web.modules.database.termine as termin
|
||||||
import Web.modules.database.user as us
|
import Web.modules.database.user as us
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from flask import make_response, flash, redirect, url_for, session
|
||||||
|
|
||||||
# Create a blueprint instance
|
# Create a blueprint instance
|
||||||
appoint_bp = Blueprint('terminplaner', __name__)
|
appoint_bp = Blueprint('terminplaner', __name__)
|
||||||
@@ -34,10 +37,70 @@ def _current_tenant_id():
|
|||||||
pass
|
pass
|
||||||
return str(session.get('tenant_id', '') or '').strip()
|
return str(session.get('tenant_id', '') or '').strip()
|
||||||
|
|
||||||
|
@appoint_bp.route('/client/<appointment_id>/export_csv')
|
||||||
|
def export_csv(appointment_id):
|
||||||
|
"""
|
||||||
|
Generiert einen CSV-Export aller Buchungen für den Ersteller.
|
||||||
|
"""
|
||||||
|
# 1. Berechtigungsprüfung (analog zur Client-Route)
|
||||||
|
current_user = str(session.get('username', '') or '').strip()
|
||||||
|
appointment_item = termin.get_item(appointment_id) or {}
|
||||||
|
appointment_owner = str(appointment_item.get('user', '') or '').strip()
|
||||||
|
|
||||||
|
can_view = False
|
||||||
|
if current_user:
|
||||||
|
try:
|
||||||
|
can_view = bool(us.check_admin(current_user) or current_user == appointment_owner)
|
||||||
|
except Exception:
|
||||||
|
can_view = bool(current_user == appointment_owner)
|
||||||
|
|
||||||
|
if not can_view:
|
||||||
|
flash('Nicht autorisiert für diesen Export.', 'error')
|
||||||
|
return redirect(url_for('terminplaner.client', appointment_id=appointment_id))
|
||||||
|
|
||||||
|
custom_fields = appointment_item.get('custom_fields', [])
|
||||||
|
bookings = appointment_item.get('slots_booked', []) or []
|
||||||
|
|
||||||
|
# 2. CSV im Speicher erstellen
|
||||||
|
si = io.StringIO()
|
||||||
|
# Wir nutzen ein Semikolon als Trennzeichen – das öffnet Excel im deutschsprachigen Raum direkt fehlerfrei
|
||||||
|
cw = csv.writer(si, delimiter=';', quoting=csv.QUOTE_MINIMAL)
|
||||||
|
|
||||||
|
# Tabellenkopf schreiben
|
||||||
|
headers = ['Zeitpunkt', 'Name'] + list(custom_fields)
|
||||||
|
cw.writerow(headers)
|
||||||
|
|
||||||
|
# Datenzeilen schreiben
|
||||||
|
for booking in bookings:
|
||||||
|
if isinstance(booking, (list, tuple)):
|
||||||
|
row = [booking[0], booking[1]]
|
||||||
|
|
||||||
|
# Antworten holen und sicherstellen, dass Lücken (falls vorhanden) mit Leerstrings gefüllt werden
|
||||||
|
answers = booking[2] if len(booking) > 2 else []
|
||||||
|
for i in range(len(custom_fields)):
|
||||||
|
if i < len(answers):
|
||||||
|
row.append(answers[i])
|
||||||
|
else:
|
||||||
|
row.append('')
|
||||||
|
cw.writerow(row)
|
||||||
|
|
||||||
|
# 3. Response vorbereiten und UTF-8-BOM für Excel mitsenden
|
||||||
|
response_content = si.getvalue()
|
||||||
|
output = make_response(response_content)
|
||||||
|
|
||||||
|
output.data = b'\xef\xbb\xbf' + output.data
|
||||||
|
|
||||||
|
# Dateiname generieren
|
||||||
|
title_slug = appointment_item.get('title', 'export').replace(' ', '_')
|
||||||
|
output.headers["Content-Disposition"] = f"attachment; filename=buchungen_{title_slug}.csv"
|
||||||
|
output.headers["Content-Type"] = "text/csv; charset=utf-8"
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
@appoint_bp.route('/client/<appointment_id>', methods=['POST', 'GET'])
|
@appoint_bp.route('/client/<appointment_id>', methods=['POST', 'GET'])
|
||||||
def client(appointment_id):
|
def client(appointment_id):
|
||||||
"""
|
"""
|
||||||
The Route for the terminplaner to work with the client
|
The Route for the terminplaner to work with the client and allow owners to manage it.
|
||||||
"""
|
"""
|
||||||
guard = _require_module_enabled()
|
guard = _require_module_enabled()
|
||||||
if guard:
|
if guard:
|
||||||
@@ -50,6 +113,10 @@ def client(appointment_id):
|
|||||||
current_user = str(session.get('username', '') or '').strip()
|
current_user = str(session.get('username', '') or '').strip()
|
||||||
appointment_item = termin.get_item(appointment_id) or {}
|
appointment_item = termin.get_item(appointment_id) or {}
|
||||||
appointment_owner = str(appointment_item.get('user', '') or '').strip()
|
appointment_owner = str(appointment_item.get('user', '') or '').strip()
|
||||||
|
|
||||||
|
# Extract the custom fields defined by the appointment owner
|
||||||
|
custom_fields = appointment_item.get('custom_fields', [])
|
||||||
|
|
||||||
can_view_booking_names = False
|
can_view_booking_names = False
|
||||||
if current_user:
|
if current_user:
|
||||||
try:
|
try:
|
||||||
@@ -70,31 +137,60 @@ def client(appointment_id):
|
|||||||
available_for_view['slots_booked'] = sanitized_bookings
|
available_for_view['slots_booked'] = sanitized_bookings
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
start_daytime = request.form.get('start_day_time')
|
action = request.form.get('action', 'book')
|
||||||
username = request.form.get('client_name')
|
|
||||||
if not start_daytime or not username:
|
|
||||||
flash('Bitte Name und gewünschte Uhrzeit angeben.', 'error')
|
|
||||||
return render_template(
|
|
||||||
'termin_client.html',
|
|
||||||
appointment_id=appointment_id,
|
|
||||||
available=available_for_view,
|
|
||||||
current_user=session.get('username', ''),
|
|
||||||
tenant_id=_current_tenant_id(),
|
|
||||||
can_view_booking_names=can_view_booking_names,
|
|
||||||
)
|
|
||||||
|
|
||||||
if appointment_service.book_slot(appointment_id, start_daytime, username):
|
# Fall 1: ADMIN/AUTOR storniert einen Termin
|
||||||
return redirect(
|
if action == 'delete' and can_view_booking_names:
|
||||||
url_for(
|
slot_time = request.form.get('slot_time')
|
||||||
'terminplaner.client_success',
|
client_name = request.form.get('target_client_name')
|
||||||
|
|
||||||
|
# Aktuelle Buchungen direkt aus dem DB-Item holen
|
||||||
|
current_slots = appointment_item.get('slots_booked', []) or []
|
||||||
|
|
||||||
|
# Filtere den zu löschenden Slot heraus (Prüfung auf Zeit und Name)
|
||||||
|
updated_slots = [
|
||||||
|
slot for slot in current_slots
|
||||||
|
if not (isinstance(slot, (list, tuple)) and slot[0] == slot_time and slot[1] == client_name)
|
||||||
|
]
|
||||||
|
|
||||||
|
# In DB schreiben via deiner existierenden termin.update() Funktion
|
||||||
|
if termin.update(appointment_id, updated_slots):
|
||||||
|
flash('Buchung wurde erfolgreich gelöscht.', 'success')
|
||||||
|
else:
|
||||||
|
flash('Fehler beim Löschen der Buchung.', 'error')
|
||||||
|
|
||||||
|
return redirect(url_for('terminplaner.client', appointment_id=appointment_id, tenant=_current_tenant_id() or None))
|
||||||
|
|
||||||
|
# Fall 2: NORMALER CLIENT bucht einen Termin
|
||||||
|
elif action == 'book':
|
||||||
|
start_daytime = request.form.get('start_day_time')
|
||||||
|
username = request.form.get('client_name')
|
||||||
|
custom_answers = request.form.getlist('custom_answers')
|
||||||
|
|
||||||
|
if not start_daytime or not username:
|
||||||
|
flash('Bitte Name und gewünschte Uhrzeit angeben.', 'error')
|
||||||
|
return render_template(
|
||||||
|
'termin_client.html',
|
||||||
appointment_id=appointment_id,
|
appointment_id=appointment_id,
|
||||||
tenant=_current_tenant_id() or None,
|
available=available_for_view,
|
||||||
start=start_daytime,
|
current_user=session.get('username', ''),
|
||||||
name=username,
|
tenant_id=_current_tenant_id(),
|
||||||
|
can_view_booking_names=can_view_booking_names,
|
||||||
|
custom_fields=custom_fields
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
flash('Der Termin konnte nicht gespeichert werden.', 'error')
|
if appointment_service.book_slot(appointment_id, start_daytime, username, custom=custom_answers):
|
||||||
|
return redirect(
|
||||||
|
url_for(
|
||||||
|
'terminplaner.client_success',
|
||||||
|
appointment_id=appointment_id,
|
||||||
|
tenant=_current_tenant_id() or None,
|
||||||
|
start=start_daytime,
|
||||||
|
name=username,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
flash('Der Termin konnte nicht gespeichert werden.', 'error')
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'termin_client.html',
|
'termin_client.html',
|
||||||
@@ -103,6 +199,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,
|
||||||
|
appointment_item=appointment_item
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -169,13 +267,13 @@ def configure():
|
|||||||
end = request.form.get('end_date')
|
end = request.form.get('end_date')
|
||||||
time = request.form.get('time_frame')
|
time = request.form.get('time_frame')
|
||||||
slots_amount = request.form.get('slots_amounts')
|
slots_amount = request.form.get('slots_amounts')
|
||||||
slot_length = request.form.get('slot_length') # Korrigiert: slot_length
|
slot_length = request.form.get('slot_length')
|
||||||
mail = request.form.get('mail', '')
|
mail = request.form.get('mail', '')
|
||||||
note = request.form.get('note', '')
|
note = request.form.get('note', '')
|
||||||
add_to_calendar = request.form.get('add_to_calendar') == 'on'
|
add_to_calendar = request.form.get('add_to_calendar') == 'on'
|
||||||
title = request.form.get('title', '').strip() # Korrigiert: title statt titel
|
title = request.form.get('title', '').strip()
|
||||||
|
custom = request.form.getlist('custom_fields')
|
||||||
|
|
||||||
# Abfrage ebenfalls auf title und slot_length angepasst
|
|
||||||
if not start or not end or not time or not slots_amount or not slot_length or not title:
|
if not start or not end or not time or not slots_amount or not slot_length or not title:
|
||||||
flash('Bitte alle Pflichtfelder ausfüllen.', 'error')
|
flash('Bitte alle Pflichtfelder ausfüllen.', 'error')
|
||||||
return render_template(
|
return render_template(
|
||||||
@@ -186,7 +284,7 @@ def configure():
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Variablen im Funktionsaufruf aktualisiert
|
# Variablen im Funktionsaufruf aktualisiert
|
||||||
result = appointment_service.new(start, end, time, slots_amount, slot_length, session["username"], mail, note, calendar_enabled=add_to_calendar, title=title)
|
result = appointment_service.new(start, end, time, slots_amount, slot_length, session["username"], mail, note, calendar_enabled=add_to_calendar, title=title, custom_fields=custom)
|
||||||
flash('Der Terminplan wurde angelegt.', 'success')
|
flash('Der Terminplan wurde angelegt.', 'success')
|
||||||
return render_template(
|
return render_template(
|
||||||
'termin_configure.html',
|
'termin_configure.html',
|
||||||
|
|||||||
@@ -1321,10 +1321,7 @@
|
|||||||
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
|
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
|
||||||
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
|
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
|
||||||
{% if current_permissions.pages.get('library_loans_admin', True) %}
|
{% if current_permissions.pages.get('library_loans_admin', True) %}
|
||||||
<li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Ausleihen</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Ausleihen / Defekte Items</a></li>
|
||||||
{% endif %}
|
|
||||||
{% if current_permissions.pages.get('admin_damaged_items', True) %}
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('admin_damaged_items') }}">Defekte Items</a></li>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if student_cards_module_enabled %}
|
{% if student_cards_module_enabled %}
|
||||||
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Bibliotheksausweis</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Bibliotheksausweis</a></li>
|
||||||
|
|||||||
@@ -39,13 +39,19 @@
|
|||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-12 col-xxl-11">
|
<div class="col-12 col-xxl-11">
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
|
|
||||||
<div class="col-12 col-lg-4">
|
<div class="col-12 col-lg-4">
|
||||||
<div class="card border-0 shadow-lg rounded-4 h-100">
|
<div class="card border-0 shadow-lg rounded-4 h-100">
|
||||||
<div class="card-body p-4 p-md-5">
|
<div class="card-body p-4 p-md-5">
|
||||||
<p class="text-uppercase text-muted fw-semibold mb-2">Terminplaner</p>
|
<p class="text-uppercase text-muted fw-semibold mb-2">Terminplaner</p>
|
||||||
<h1 class="h3 fw-bold mb-3">Termin buchen - {{ available.title }}</h1>
|
{% if can_view_booking_names %}
|
||||||
<p class="text-muted mb-4">Wählen Sie im Kalender einen freien Slot aus. Den gewählten Termin können Sie danach direkt wie in einem Kalender-Block verschieben.</p>
|
<h1 class="h3 fw-bold mb-3">Termin Übersicht - {{ available.title }}</h1>
|
||||||
|
<p class="text-muted mb-4">Hier können Sie alle Buchungen, die bereits eingegangen sind, einsehen und verwalten.</p>
|
||||||
|
{% else %}
|
||||||
|
<h1 class="h3 fw-bold mb-3">Termin buchen - {{ available.title }}</h1>
|
||||||
|
<p class="text-muted mb-4">Wählen Sie im Kalender einen freien Slot aus. Den gewählten Termin können Sie danach direkt wie in einem Kalender-Block verschieben.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="p-3 rounded-3 bg-light mb-3">
|
<div class="p-3 rounded-3 bg-light mb-3">
|
||||||
<div class="fw-semibold">Zeitraum</div>
|
<div class="fw-semibold">Zeitraum</div>
|
||||||
<div>{{ available.date_start }} bis {{ available.date_end }}</div>
|
<div>{{ available.date_start }} bis {{ available.date_end }}</div>
|
||||||
@@ -58,23 +64,24 @@
|
|||||||
<div class="fw-semibold">Slot-Länge</div>
|
<div class="fw-semibold">Slot-Länge</div>
|
||||||
<div>{{ available.slot_lenght }} Minuten</div>
|
<div>{{ available.slot_lenght }} Minuten</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if not can_view_booking_names %}
|
||||||
<div class="p-3 rounded-3 bg-light mb-3">
|
<div class="p-3 rounded-3 bg-light mb-3">
|
||||||
<div class="fw-semibold mb-2">Gewählter Termin</div>
|
<div class="fw-semibold mb-2">Gewählter Termin</div>
|
||||||
<div id="selected-slot-badge" class="text-muted small">Noch kein Slot ausgewählt.</div>
|
<div id="selected-slot-badge" class="text-muted small">Noch kein Slot ausgewählt.</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if available.slots_booked %}
|
{% if available.slots_booked %}
|
||||||
<div class="p-3 rounded-3 bg-light">
|
<div class="p-3 rounded-3 bg-light">
|
||||||
<div class="fw-semibold mb-2">Bereits gebucht</div>
|
<div class="fw-semibold mb-2">Bereits gebucht</div>
|
||||||
<ul class="mb-0 small">
|
<ul class="mb-0 small text-muted">
|
||||||
{% for booking in available.slots_booked %}
|
{% for booking in available.slots_booked %}
|
||||||
<li>
|
<li>
|
||||||
{{ booking.start }}
|
{% if booking is mapping %}
|
||||||
{% if can_view_booking_names and booking.name %}
|
{{ booking.get('start', '') }} - Belegt
|
||||||
- {{ booking.name }}
|
|
||||||
{% else %}
|
{% else %}
|
||||||
- Belegt
|
{{ booking[0] }} - {% if can_view_booking_names %}<span class="fw-semibold text-dark">{{ booking[1] }}</span>{% else %}Belegt{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -84,39 +91,161 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12 col-lg-8">
|
<div class="col-12 col-lg-8">
|
||||||
<div class="card border-0 shadow-lg rounded-4 h-100">
|
<div class="card border-0 shadow-lg rounded-4 h-100">
|
||||||
<div class="card-body p-4 p-md-5 bg-white">
|
<div class="card-body p-4 p-md-5 bg-white">
|
||||||
<h2 class="h4 fw-bold mb-3">Termin im Kalender auswählen</h2>
|
|
||||||
|
{% if can_view_booking_names %}
|
||||||
<div class="day-slider-wrap mb-3">
|
<!-- ADMIN / AUTOREN ANSICHT -->
|
||||||
<div class="d-flex justify-content-between align-items-center gap-2 mb-2">
|
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
|
||||||
<span class="small text-muted">Tag wählen</span>
|
<div>
|
||||||
<strong id="day-slider-label" class="small"></strong>
|
<h2 class="h4 fw-bold mb-0">Eingegangene Buchungen</h2>
|
||||||
|
<div class="text-muted small">Verwalten Sie die Termine Ihrer Klienten</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 align-self-start align-self-md-center">
|
||||||
|
<!-- Neuer CSV Export Button -->
|
||||||
|
<a href="{{ url_for('terminplaner.export_csv', appointment_id=appointment_id) }}" class="btn btn-outline-success rounded-3 d-inline-flex align-items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-download" viewBox="0 0 16 16">
|
||||||
|
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v2.5a.5.5 0 0 1 .5-.5"/>
|
||||||
|
<path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708z"/>
|
||||||
|
</svg>
|
||||||
|
Excel / CSV Export
|
||||||
|
</a>
|
||||||
|
<span class="badge bg-primary rounded-pill fs-6 px-3 d-flex align-items-center">
|
||||||
|
{{ available.slots_total - available.slots_left }} von {{ available.slots_total }} belegt
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input id="day-slider" type="range" class="form-range m-0" min="0" max="0" value="0">
|
<div class="bg-light p-3 rounded-3 mb-4">
|
||||||
</div>
|
<div class="row g-2 align-items-center">
|
||||||
|
<div class="col-12 col-md-8">
|
||||||
<div id="client-slot-calendar" class="mb-4"></div>
|
<label for="table-search" class="form-label small fw-semibold text-muted mb-1">Live-Filter (Name, Uhrzeit, Zusatzfelder...)</label>
|
||||||
|
<input type="text" id="table-search" class="form-control form-control-lg bg-white border" placeholder="Suchbegriff eingeben...">
|
||||||
<form id="client-booking-form" method="post" action="{{ url_for('terminplaner.client', appointment_id=appointment_id, tenant=tenant_id) }}" class="vstack gap-3">
|
</div>
|
||||||
<div>
|
<div class="col-12 col-md-4 d-flex align-items-end h-100 mt-md-4">
|
||||||
<label for="start_day_time" class="form-label fw-semibold">Gewünschter Zeitpunkt</label>
|
<button type="button" id="clear-filter-btn" class="btn btn-outline-secondary w-100 btn-lg">Filter zurücksetzen</button>
|
||||||
<input type="text" id="start_day_time" name="start_day_time" class="form-control form-control-lg" placeholder="Bitte im Kalender auswählen" readonly required>
|
</div>
|
||||||
<div class="form-text">Klicken Sie auf einen freien Slot oder verschieben Sie den gewählten Block.</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label for="client_name" class="form-label fw-semibold">Ihr Name</label>
|
{% if available.slots_booked %}
|
||||||
<input type="text" id="client_name" name="client_name" class="form-control form-control-lg" placeholder="Vor- und Nachname" required>
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle border-top" id="admin-booking-table">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="py-3">Zeitpunkt</th>
|
||||||
|
<th scope="col" class="py-3">Name</th>
|
||||||
|
<!-- Spaltenüberschriften für Custom Fields -->
|
||||||
|
{% for field_label in custom_fields %}
|
||||||
|
<th scope="col" class="py-3">{{ field_label }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
<th scope="col" class="py-3 text-end">Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="booking-table-body">
|
||||||
|
{# Wir loopen jetzt über die rohen DB-Daten statt über die beschnittene Service-Funktion #}
|
||||||
|
{% for booking in appointment_item.get('slots_booked', []) %}
|
||||||
|
<tr>
|
||||||
|
<!-- 1. ZEITPUNKT -->
|
||||||
|
<td class="fw-semibold text-primary py-3 text-nowrap">
|
||||||
|
{{ booking[0] }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- 2. CLIENT NAME -->
|
||||||
|
<td>
|
||||||
|
<span class="fw-bold text-dark">{{ booking[1] }}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- 3. CUSTOM FIELDS (Präziser Index-Abgleich gegen Spaltenkopf) -->
|
||||||
|
{% for field in custom_fields %}
|
||||||
|
<td>
|
||||||
|
{# Prüft ob für dieses Feld eine Antwort am exakt gleichen Index existiert #}
|
||||||
|
{% if booking|length > 2 and booking[2] and booking[2]|length > loop.index0 %}
|
||||||
|
{% set answer = booking[2][loop.index0] %}
|
||||||
|
{{ answer if answer else '<span class="text-muted small">-</span>'|safe }}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted small">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- 4. AKTION (LÖSCHEN) -->
|
||||||
|
<td class="text-end">
|
||||||
|
<form method="post" action="{{ url_for('terminplaner.client', appointment_id=appointment_id, tenant=tenant_id) }}" onsubmit="return confirm('Möchten Sie diesen Termin wirklich löschen?');" class="d-inline">
|
||||||
|
<input type="hidden" name="action" value="delete">
|
||||||
|
<input type="hidden" name="slot_time" value="{{ booking[0] }}">
|
||||||
|
<input type="hidden" name="target_client_name" value="{{ booking[1] }}">
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger px-3 rounded-3">
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-column flex-sm-row gap-2 pt-2">
|
|
||||||
<button type="submit" class="btn btn-primary btn-lg">Termin buchen</button>
|
<div id="no-results-msg" class="text-center py-4 text-muted d-none">
|
||||||
|
Keine Buchungen entsprechen Ihrem Filter.
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<p class="mb-0">Bisher sind noch keine Buchungen für diesen Terminplaner eingegangen.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="pt-4 border-top mt-4">
|
||||||
<a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main', tenant=tenant_id) }}">Zur Übersicht</a>
|
<a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main', tenant=tenant_id) }}">Zur Übersicht</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
|
{% else %}
|
||||||
|
<h2 class="h4 fw-bold mb-3">Termin im Kalender auswählen</h2>
|
||||||
|
|
||||||
|
<div class="day-slider-wrap mb-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center gap-2 mb-2">
|
||||||
|
<span class="small text-muted">Tag wählen</span>
|
||||||
|
<strong id="day-slider-label" class="small"></strong>
|
||||||
|
</div>
|
||||||
|
<input id="day-slider" type="range" class="form-range m-0" min="0" max="0" value="0">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="client-slot-calendar" class="mb-4"></div>
|
||||||
|
|
||||||
|
<form id="client-booking-form" method="post" action="{{ url_for('terminplaner.client', appointment_id=appointment_id, tenant=tenant_id) }}" class="vstack gap-3">
|
||||||
|
<div>
|
||||||
|
<label for="start_day_time" class="form-label fw-semibold">Gewünschter Zeitpunkt</label>
|
||||||
|
<input type="text" id="start_day_time" name="start_day_time" class="form-control form-control-lg" placeholder="Bitte im Kalender auswählen" readonly required>
|
||||||
|
<div class="form-text">Klicken Sie auf einen freien Slot oder verschieben Sie den gewählten Block.</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="client_name" class="form-label fw-semibold">Ihr Name</label>
|
||||||
|
<input type="text" id="client_name" name="client_name" class="form-control form-control-lg" placeholder="Vor- und Nachname" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if custom_fields %}
|
||||||
|
{% for field_label in custom_fields %}
|
||||||
|
<div>
|
||||||
|
<label class="form-label fw-semibold">{{ field_label }}</label>
|
||||||
|
<input type="text" name="custom_answers" class="form-control form-control-lg" placeholder="{{ field_label }} eingeben" required>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="d-flex flex-column flex-sm-row gap-2 pt-2">
|
||||||
|
<input type="hidden" name="action" value="book">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">Termin buchen</button>
|
||||||
|
<a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main', tenant=tenant_id) }}">Zur Übersicht</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,6 +271,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const searchInput = document.getElementById('table-search');
|
||||||
|
const clearBtn = document.getElementById('clear-filter-btn');
|
||||||
|
const tableBody = document.getElementById('booking-table-body');
|
||||||
|
const noResultsMsg = document.getElementById('no-results-msg');
|
||||||
|
|
||||||
|
if (searchInput && tableBody) {
|
||||||
|
const rows = tableBody.querySelectorAll('tr');
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', function () {
|
||||||
|
const query = this.value.toLowerCase().trim();
|
||||||
|
let visibleRowsCount = 0;
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
// Liest den Textinhalt aller Spalten der Zeile aus
|
||||||
|
const rowText = row.textContent.toLowerCase();
|
||||||
|
|
||||||
|
if (rowText.includes(query)) {
|
||||||
|
row.classList.remove('d-none');
|
||||||
|
visibleRowsCount++;
|
||||||
|
} else {
|
||||||
|
row.classList.add('d-none');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Falls kein Filterergebnis gefunden wurde, Nachricht einblenden
|
||||||
|
if (visibleRowsCount === 0 && query !== '') {
|
||||||
|
noResultsMsg.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
noResultsMsg.classList.add('d-none');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter zurücksetzen Button
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.addEventListener('click', function () {
|
||||||
|
searchInput.value = '';
|
||||||
|
rows.forEach(row => row.classList.remove('d-none'));
|
||||||
|
noResultsMsg.classList.add('d-none');
|
||||||
|
searchInput.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
|||||||
@@ -77,6 +77,16 @@
|
|||||||
<div class="form-text">Wird aus der gebuchten Zeit (abzüglich Pausen) und der Slot-Länge berechnet.</div>
|
<div class="form-text">Wird aus der gebuchten Zeit (abzüglich Pausen) und der Slot-Länge berechnet.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="custom-fields-container" class="mt-4">
|
||||||
|
<h3 class="mb-3">Custom Fields</h3>
|
||||||
|
|
||||||
|
<div class="mb-3 custom-field-row">
|
||||||
|
<input type="text"
|
||||||
|
name="custom_fields"
|
||||||
|
class="form-control form-control-lg custom-dynamic-input"
|
||||||
|
placeholder="Zusatzfeld hinzufügen (z.B. Firma, Kundennummer...)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if mail_service_enabled %}
|
{% if mail_service_enabled %}
|
||||||
<div>
|
<div>
|
||||||
@@ -351,6 +361,39 @@
|
|||||||
syncTextarea();
|
syncTextarea();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const container = document.getElementById('custom-fields-container');
|
||||||
|
|
||||||
|
// Monitor inputs inside the container using event delegation
|
||||||
|
container.addEventListener('input', function (event) {
|
||||||
|
// Only trigger if the user is typing inside our dynamic inputs
|
||||||
|
if (event.target.classList.contains('custom-dynamic-input')) {
|
||||||
|
const allInputs = container.querySelectorAll('.custom-dynamic-input');
|
||||||
|
const lastInput = allInputs[allInputs.length - 1];
|
||||||
|
|
||||||
|
// If the user typed something into the absolute last input field, spawn a new one
|
||||||
|
if (lastInput.value.trim() !== '') {
|
||||||
|
createNewFieldRow(container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to generate and append a clean new input row
|
||||||
|
function createNewFieldRow(targetContainer) {
|
||||||
|
const rowDiv = document.createElement('div');
|
||||||
|
rowDiv.className = 'mb-3 custom-field-row';
|
||||||
|
|
||||||
|
rowDiv.innerHTML = `
|
||||||
|
<input type="text"
|
||||||
|
name="custom_fields"
|
||||||
|
class="form-control form-control-lg custom-dynamic-input"
|
||||||
|
placeholder="Nächstes Zusatzfeld...">
|
||||||
|
`;
|
||||||
|
|
||||||
|
targetContainer.appendChild(rowDiv);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Event Listeners Registration
|
// Event Listeners Registration
|
||||||
buildButton.addEventListener('click', renderRows);
|
buildButton.addEventListener('click', renderRows);
|
||||||
startDateInput.addEventListener('change', renderRows);
|
startDateInput.addEventListener('change', renderRows);
|
||||||
|
|||||||
@@ -954,7 +954,7 @@
|
|||||||
<div class="isbn-input-group">
|
<div class="isbn-input-group">
|
||||||
<input type="text" id="isbn" name="isbn" placeholder="ISBN oder Barcode eingeben..." required>
|
<input type="text" id="isbn" name="isbn" placeholder="ISBN oder Barcode eingeben..." required>
|
||||||
<button type="button" id="scan-isbn-btn" class="fetch-isbn-button">Barcode scannen</button>
|
<button type="button" id="scan-isbn-btn" class="fetch-isbn-button">Barcode scannen</button>
|
||||||
<button type="button" class="fetch-isbn-button" onclick="fetchBookInfo('upload')">Bild abrufen</button>
|
<button type="button" class="fetch-isbn-button" onclick="fetchBookInfo('upload')">Informationen abrufen</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="isbn-scanner" style="width:100%; max-width:520px; display:none; margin-top:10px;"></div>
|
<div id="isbn-scanner" style="width:100%; max-width:520px; display:none; margin-top:10px;"></div>
|
||||||
<small id="isbn-scan-status" style="display:block; color:#666; margin-top:6px;">Scannen oder manuell eingeben. Gültige ISBNs helfen beim Abruf von Buchdaten, andere Codes werden trotzdem akzeptiert.</small>
|
<small id="isbn-scan-status" style="display:block; color:#666; margin-top:6px;">Scannen oder manuell eingeben. Gültige ISBNs helfen beim Abruf von Buchdaten, andere Codes werden trotzdem akzeptiert.</small>
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ services:
|
|||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
environment:
|
environment:
|
||||||
MONGO_INITDB_DATABASE: inventar_default
|
MONGO_INITDB_DATABASE: inventar_default
|
||||||
|
|
||||||
|
|||||||
+108
-136
@@ -23,70 +23,19 @@ ensure_runtime_config_json() {
|
|||||||
echo "Warning: moved unexpected directory $config_path to $backup_path"
|
echo "Warning: moved unexpected directory $config_path to $backup_path"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
FORCE_REMOVE=false
|
if [ ! -f "$config_path" ]; then
|
||||||
if [ "${2:-}" = "--yes" ] || [ "${2:-}" = "-y" ]; then
|
|
||||||
FORCE_REMOVE=true
|
|
||||||
TENANT_ID="${3:-}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$TENANT_ID" ]; then
|
|
||||||
cat > "$config_path" <<'EOF'
|
cat > "$config_path" <<'EOF'
|
||||||
{
|
{
|
||||||
"ver": "2.6.5",
|
"ver": "2.6.5",
|
||||||
|
"tenants": {}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
echo "Created default config.json"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
if [ "$FORCE_REMOVE" != true ]; then
|
get_tenant_aliases() {
|
||||||
echo -n "WARNING: Are you sure you want to permanently delete all data for tenant '$TENANT_ID'? (y/N) "
|
local tenant_id="$1"
|
||||||
read confirm
|
|
||||||
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
|
|
||||||
echo "Removal canceled."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Removing tenant '$TENANT_ID'..."
|
|
||||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
|
||||||
port_to_remove=""
|
|
||||||
if [ -n "$APP_CONTAINER" ]; then
|
|
||||||
port_to_remove="$({ docker exec "$APP_CONTAINER" python3 - "$TENANT_ID" <<'PY'
|
|
||||||
import sys
|
|
||||||
sys.path.insert(0, '/app')
|
|
||||||
sys.path.insert(0, '/app/Web')
|
|
||||||
from tenant import delete_tenant, get_tenant_config
|
|
||||||
|
|
||||||
tenant_id = sys.argv[1]
|
|
||||||
tenant_cfg = get_tenant_config(tenant_id)
|
|
||||||
port = tenant_cfg.get('port')
|
|
||||||
|
|
||||||
if not delete_tenant(tenant_id):
|
|
||||||
print(f'Error: failed to delete tenant {tenant_id}', file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if port is not None:
|
|
||||||
print(port)
|
|
||||||
PY
|
|
||||||
} 2>/dev/null)"
|
|
||||||
echo "Tenant '$TENANT_ID' database and config removed."
|
|
||||||
else
|
|
||||||
echo "Warning: Application container not running. Tenant database may still exist in MongoDB."
|
|
||||||
if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
|
|
||||||
:
|
|
||||||
else
|
|
||||||
echo "Warning: tenant '$TENANT_ID' was not configured in config.json or could not be removed."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$port_to_remove" ]; then
|
|
||||||
remove_runtime_port "$port_to_remove"
|
|
||||||
fi
|
|
||||||
sync_tenant_port_map
|
|
||||||
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
|
|
||||||
restart_app_container
|
|
||||||
fi
|
|
||||||
if [ -n "$port_to_remove" ]; then
|
|
||||||
echo "Removed tenant '$TENANT_ID' and cleaned runtime port $port_to_remove."
|
|
||||||
else
|
|
||||||
echo "Removed tenant '$TENANT_ID'. No port mapping was present."
|
|
||||||
fi
|
|
||||||
local normalized alias
|
local normalized alias
|
||||||
normalized="$(printf '%s' "$tenant_id" | tr '[:upper:]' '[:lower:]')"
|
normalized="$(printf '%s' "$tenant_id" | tr '[:upper:]' '[:lower:]')"
|
||||||
printf '%s\n' "$tenant_id"
|
printf '%s\n' "$tenant_id"
|
||||||
@@ -212,7 +161,7 @@ existing['modules'] = {
|
|||||||
'inventory': {'enabled': True},
|
'inventory': {'enabled': True},
|
||||||
'library': {'enabled': True},
|
'library': {'enabled': True},
|
||||||
'student_cards': {'enabled': True, 'default_borrow_days': 14, 'max_borrow_days': 365},
|
'student_cards': {'enabled': True, 'default_borrow_days': 14, 'max_borrow_days': 365},
|
||||||
'terminplan': {'enabled': True},
|
'terminplan': {'enabled': True},
|
||||||
'mail': {'enabled': True},
|
'mail': {'enabled': True},
|
||||||
}
|
}
|
||||||
existing['trial'] = trial_config
|
existing['trial'] = trial_config
|
||||||
@@ -245,8 +194,8 @@ initialize_tenant_database() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
docker exec "$APP_CONTAINER" python3 -c '
|
docker exec -i "$APP_CONTAINER" python3 - "$tenant_id" "$mode" <<'PY'
|
||||||
import sys, re, datetime, hashlib
|
import sys, os, re, datetime, hashlib
|
||||||
sys.path.insert(0, "/app")
|
sys.path.insert(0, "/app")
|
||||||
sys.path.insert(0, "/app/Web")
|
sys.path.insert(0, "/app/Web")
|
||||||
from Web.modules.database import settings
|
from Web.modules.database import settings
|
||||||
@@ -258,7 +207,11 @@ sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_")
|
|||||||
db_name = f"inventar_{sanitized}"
|
db_name = f"inventar_{sanitized}"
|
||||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||||
db = client[db_name]
|
db = client[db_name]
|
||||||
hashed_pw = hashlib.sha512("admin123".encode()).hexdigest()
|
|
||||||
|
pw_bytes = "admin123".encode("utf-8")
|
||||||
|
random_salt = os.urandom(16)
|
||||||
|
hashed = hashlib.scrypt(pw_bytes, salt=random_salt, n=16384, r=8, p=1)
|
||||||
|
hashed_pw_string = f"v1${random_salt.hex()}${hashed.hex()}"
|
||||||
|
|
||||||
action_permissions = {
|
action_permissions = {
|
||||||
"can_borrow": True,
|
"can_borrow": True,
|
||||||
@@ -294,7 +247,7 @@ page_permissions = {
|
|||||||
if db.users.count_documents({"Username": "admin"}) == 0:
|
if db.users.count_documents({"Username": "admin"}) == 0:
|
||||||
db.users.insert_one({
|
db.users.insert_one({
|
||||||
"Username": "admin",
|
"Username": "admin",
|
||||||
"Password": hashed_pw,
|
"Password": hashed_pw_string,
|
||||||
"Admin": True,
|
"Admin": True,
|
||||||
"active_ausleihung": None,
|
"active_ausleihung": None,
|
||||||
"name": "Admin",
|
"name": "Admin",
|
||||||
@@ -319,7 +272,7 @@ if mode == "trial":
|
|||||||
)
|
)
|
||||||
|
|
||||||
print(f"Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123")
|
print(f"Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123")
|
||||||
' "$tenant_id" "$mode"
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
update_runtime_ports() {
|
update_runtime_ports() {
|
||||||
@@ -435,7 +388,6 @@ restart_app_container() {
|
|||||||
|
|
||||||
ensure_runtime_config_json
|
ensure_runtime_config_json
|
||||||
|
|
||||||
# If HOST_WORKDIR is set (called from container), use absolute paths so docker daemon resolves them correctly
|
|
||||||
if [ -n "${HOST_WORKDIR:-}" ]; then
|
if [ -n "${HOST_WORKDIR:-}" ]; then
|
||||||
workdir="$HOST_WORKDIR"
|
workdir="$HOST_WORKDIR"
|
||||||
compose_args+=( -f "$(readlink -f "$HOST_WORKDIR/docker-compose-multitenant.yml")" )
|
compose_args+=( -f "$(readlink -f "$HOST_WORKDIR/docker-compose-multitenant.yml")" )
|
||||||
@@ -446,7 +398,6 @@ restart_app_container() {
|
|||||||
compose_args+=( --env-file "$(readlink -f "$HOST_WORKDIR/.docker-build.env")" )
|
compose_args+=( --env-file "$(readlink -f "$HOST_WORKDIR/.docker-build.env")" )
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
# Normal case: called directly from host
|
|
||||||
compose_args+=( -f "$workdir/docker-compose-multitenant.yml" )
|
compose_args+=( -f "$workdir/docker-compose-multitenant.yml" )
|
||||||
if [ -f "$workdir/.docker-compose.runtime.override.yml" ]; then
|
if [ -f "$workdir/.docker-compose.runtime.override.yml" ]; then
|
||||||
compose_args+=( -f "$workdir/.docker-compose.runtime.override.yml" )
|
compose_args+=( -f "$workdir/.docker-compose.runtime.override.yml" )
|
||||||
@@ -456,7 +407,6 @@ restart_app_container() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Pass along COMPOSE_PROJECT_NAME if set so the internal docker-compose sees it
|
|
||||||
if [ -n "${COMPOSE_PROJECT_NAME:-}" ]; then
|
if [ -n "${COMPOSE_PROJECT_NAME:-}" ]; then
|
||||||
compose_args=( -p "$COMPOSE_PROJECT_NAME" "${compose_args[@]}" )
|
compose_args=( -p "$COMPOSE_PROJECT_NAME" "${compose_args[@]}" )
|
||||||
fi
|
fi
|
||||||
@@ -566,24 +516,83 @@ remove_runtime_port() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
show_help() {
|
||||||
|
local HEADER='\033[95m'
|
||||||
|
local BLUE='\033[94m'
|
||||||
|
local GREEN='\033[92m'
|
||||||
|
local YELLOW='\033[93m'
|
||||||
|
local RED='\033[91m'
|
||||||
|
local BOLD='\033[1m'
|
||||||
|
local RESET='\033[0m'
|
||||||
|
|
||||||
|
cat << EOF
|
||||||
|
|
||||||
|
${HEADER}${BOLD}=== MULTI-TENANT INVENTAR MANAGER ===${RESET}
|
||||||
|
${YELLOW}Nutzung:${RESET} $0 <befehl> [tenant_id] [optionen]
|
||||||
|
|
||||||
|
${BLUE}${BOLD}VERFÜGBARE BEFEHLE:${RESET}
|
||||||
|
${GREEN}add${RESET} <tenant_id> [port]
|
||||||
|
Legt einen neuen Tenant an, registriert den Port und initialisiert
|
||||||
|
die MongoDB-Datenbank mit einem Standard-Admin (${YELLOW}admin / admin123${RESET}).
|
||||||
|
|
||||||
|
${GREEN}trial${RESET} <tenant_id> [port] [tage]
|
||||||
|
Erstellt einen temporären Test-Tenant. Standardlaufzeit: 7 Tage.
|
||||||
|
Läuft automatisch ab und löscht sich selbst.
|
||||||
|
|
||||||
|
${GREEN}remove${RESET} [-y|--yes] <tenant_id>
|
||||||
|
Löscht einen Tenant, seine Konfiguration, Ports und die Datenbank.
|
||||||
|
Nutze ${RED}-y${RESET}, um die Bestätigungsabfrage zu überspringen.
|
||||||
|
|
||||||
|
${GREEN}restart-tenant${RESET} <tenant_id>
|
||||||
|
Startet einen spezifischen Tenant neu, indem alle aktiven Sessions
|
||||||
|
und der Cache in der MongoDB geleert werden (Sitzungs-Reset).
|
||||||
|
|
||||||
|
${GREEN}restart-all${RESET}
|
||||||
|
Führt einen Zero-Downtime Rolling-Restart für alle App-Container durch.
|
||||||
|
|
||||||
|
${GREEN}list${RESET}
|
||||||
|
Listet alle registrierten Tenants aus der 'config.json' sowie alle
|
||||||
|
aktiven Tenant-Datenbanken aus der MongoDB auf.
|
||||||
|
|
||||||
|
${GREEN}module${RESET} <tenant_id> <modulname>=<on|off> ...
|
||||||
|
Aktiviert oder deaktiviert bestimmte Features/Module für einen Tenant.
|
||||||
|
Es können mehrere Module gleichzeitig übergeben werden.
|
||||||
|
|
||||||
|
${BLUE}${BOLD}GLOBALE OPTIONEN:${RESET}
|
||||||
|
${GREEN}-h, --help${RESET}
|
||||||
|
Zeigt diese Hilfe an.
|
||||||
|
|
||||||
|
${YELLOW}Beispiele:${RESET}
|
||||||
|
$0 add schule_muenchen 8081
|
||||||
|
$0 trial test_user 8082 14
|
||||||
|
$0 module schule_muenchen barre_code=on leih_historie=off
|
||||||
|
$0 remove -y test_user
|
||||||
|
|
||||||
|
-----------------------------------------------------------------
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
if [ -z "${1:-}" ]; then
|
if [ -z "${1:-}" ]; then
|
||||||
show_help
|
show_help
|
||||||
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
COMMAND="$1"
|
COMMAND="$1"
|
||||||
TENANT_ID="${2:-}"
|
|
||||||
|
# === MAIN ROUTING BLOCK ===
|
||||||
|
|
||||||
case "$COMMAND" in
|
case "$COMMAND" in
|
||||||
-h|--help)
|
-h|--help)
|
||||||
show_help
|
show_help
|
||||||
;;
|
;;
|
||||||
add)
|
add)
|
||||||
|
TENANT_ID="${2:-}"
|
||||||
if [ -z "$TENANT_ID" ]; then
|
if [ -z "$TENANT_ID" ]; then
|
||||||
echo "Error: Please provide a tenant_id."
|
echo "Error: Please provide a tenant_id."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
PORT_ARG="$3"
|
PORT_ARG="${3:-}"
|
||||||
if [ -n "$PORT_ARG" ]; then
|
if [ -n "$PORT_ARG" ]; then
|
||||||
if ! printf '%s\n' "$PORT_ARG" | grep -qE '^[0-9]+$'; then
|
if ! printf '%s\n' "$PORT_ARG" | grep -qE '^[0-9]+$'; then
|
||||||
echo "Error: Port must be a numeric value."
|
echo "Error: Port must be a numeric value."
|
||||||
@@ -598,68 +607,13 @@ case "$COMMAND" in
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Adding new tenant '$TENANT_ID'..."
|
echo "Adding new tenant '$TENANT_ID'..."
|
||||||
# Initialize tenant database via Python inside container
|
|
||||||
echo "Initializing database for $TENANT_ID..."
|
echo "Initializing database for $TENANT_ID..."
|
||||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
initialize_tenant_database "$TENANT_ID" "standard"
|
||||||
if [ -n "$APP_CONTAINER" ]; then
|
echo "Tenant '$TENANT_ID' successfully added. Ready to use."
|
||||||
docker exec $APP_CONTAINER python3 -c "
|
|
||||||
import sys, re; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from Web.modules.database import settings; from pymongo import MongoClient; import hashlib
|
|
||||||
tenant_id = sys.argv[1].lower()
|
|
||||||
sanitized = ''.join(c for c in tenant_id if c.isalnum() or c == '_')
|
|
||||||
db_name = f'inventar_{sanitized}'
|
|
||||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
|
||||||
db = client[db_name]
|
|
||||||
hashed_pw = hashlib.sha512('admin123'.encode()).hexdigest()
|
|
||||||
if db.users.count_documents({'Username': 'admin'}) == 0:
|
|
||||||
db.users.insert_one({
|
|
||||||
'Username': 'admin',
|
|
||||||
'Password': hashed_pw,
|
|
||||||
'Admin': True,
|
|
||||||
'active_ausleihung': None,
|
|
||||||
'name': 'Admin',
|
|
||||||
'last_name': 'User',
|
|
||||||
'IsStudent': False,
|
|
||||||
'PermissionPreset': 'full_access',
|
|
||||||
'ActionPermissions': {
|
|
||||||
'can_borrow': True,
|
|
||||||
'can_insert': True,
|
|
||||||
'can_edit': True,
|
|
||||||
'can_delete': True,
|
|
||||||
'can_manage_users': True,
|
|
||||||
'can_manage_settings': True,
|
|
||||||
'can_view_logs': True,
|
|
||||||
},
|
|
||||||
'PagePermissions': {
|
|
||||||
'home': True,
|
|
||||||
'tutorial_page': True,
|
|
||||||
'my_borrowed_items': True,
|
|
||||||
'notifications_view': True,
|
|
||||||
'impressum': True,
|
|
||||||
'license': True,
|
|
||||||
'library_view': True,
|
|
||||||
'terminplan': True,
|
|
||||||
'home_admin': True,
|
|
||||||
'upload_admin': True,
|
|
||||||
'library_admin': True,
|
|
||||||
'admin_borrowings': True,
|
|
||||||
'library_loans_admin': True,
|
|
||||||
'admin_damaged_items': True,
|
|
||||||
'admin_audit_dashboard': True,
|
|
||||||
'logs': True,
|
|
||||||
'manage_filters': True,
|
|
||||||
'manage_locations': True,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123')
|
|
||||||
" "$TENANT_ID"
|
|
||||||
echo "Tenant '$TENANT_ID' successfully added. Ready to use."
|
|
||||||
else
|
|
||||||
echo "Warning: Application container is not running. Please start the multi-tenant system first."
|
|
||||||
echo "Data will be initialized upon first access by the tenant."
|
|
||||||
fi
|
|
||||||
;;
|
;;
|
||||||
|
|
||||||
trial)
|
trial)
|
||||||
|
TENANT_ID="${2:-}"
|
||||||
if [ -z "$TENANT_ID" ]; then
|
if [ -z "$TENANT_ID" ]; then
|
||||||
echo "Error: Please provide a tenant_id."
|
echo "Error: Please provide a tenant_id."
|
||||||
exit 1
|
exit 1
|
||||||
@@ -691,9 +645,13 @@ print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin1
|
|||||||
|
|
||||||
remove)
|
remove)
|
||||||
FORCE_REMOVE=false
|
FORCE_REMOVE=false
|
||||||
if [ "${2:-}" = "--yes" ] || [ "${2:-}" = "-y" ]; then
|
TENANT_ARG="${2:-}"
|
||||||
|
|
||||||
|
if [ "$TENANT_ARG" = "--yes" ] || [ "$TENANT_ARG" = "-y" ]; then
|
||||||
FORCE_REMOVE=true
|
FORCE_REMOVE=true
|
||||||
TENANT_ID="${3:-}"
|
TENANT_ID="${3:-}"
|
||||||
|
else
|
||||||
|
TENANT_ID="$TENANT_ARG"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "$TENANT_ID" ]; then
|
if [ -z "$TENANT_ID" ]; then
|
||||||
@@ -713,17 +671,32 @@ print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin1
|
|||||||
echo "Removing tenant '$TENANT_ID'..."
|
echo "Removing tenant '$TENANT_ID'..."
|
||||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||||
port_to_remove=""
|
port_to_remove=""
|
||||||
|
|
||||||
if [ -n "$APP_CONTAINER" ]; then
|
if [ -n "$APP_CONTAINER" ]; then
|
||||||
|
# Zuerst die Datenbank via PyMongo hart droppen (Erzwungenes Löschen)
|
||||||
port_to_remove="$(docker exec "$APP_CONTAINER" python3 - "$TENANT_ID" <<'PY'
|
port_to_remove="$(docker exec "$APP_CONTAINER" python3 - "$TENANT_ID" <<'PY'
|
||||||
import sys
|
import sys, re
|
||||||
sys.path.insert(0, '/app')
|
sys.path.insert(0, '/app')
|
||||||
sys.path.insert(0, '/app/Web')
|
sys.path.insert(0, '/app/Web')
|
||||||
from tenant import delete_tenant, get_tenant_config
|
from tenant import delete_tenant, get_tenant_config
|
||||||
|
from Web.modules.database import settings
|
||||||
|
from pymongo import MongoClient
|
||||||
|
|
||||||
tenant_id = sys.argv[1]
|
tenant_id = sys.argv[1]
|
||||||
tenant_cfg = get_tenant_config(tenant_id)
|
tenant_cfg = get_tenant_config(tenant_id)
|
||||||
port = tenant_cfg.get('port')
|
port = tenant_cfg.get('port')
|
||||||
|
|
||||||
|
# Datenbanknamen exakt rekonstruieren
|
||||||
|
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))
|
||||||
|
client.drop_database(db_name)
|
||||||
|
print(f"MongoDB database '{db_name}' dropped successfully.", file=sys.stderr)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not drop database '{db_name}': {e}", file=sys.stderr)
|
||||||
|
|
||||||
if not delete_tenant(tenant_id):
|
if not delete_tenant(tenant_id):
|
||||||
print(f'Error: failed to delete tenant {tenant_id}', file=sys.stderr)
|
print(f'Error: failed to delete tenant {tenant_id}', file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -757,20 +730,19 @@ PY
|
|||||||
;;
|
;;
|
||||||
|
|
||||||
restart-tenant)
|
restart-tenant)
|
||||||
|
TENANT_ID="${2:-}"
|
||||||
if [ -z "$TENANT_ID" ]; then
|
if [ -z "$TENANT_ID" ]; then
|
||||||
echo "Error: Please provide a tenant_id."
|
echo "Error: Please provide a tenant_id."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Restarting tenant '$TENANT_ID' (clearing session/cache)..."
|
echo "Restarting tenant '$TENANT_ID' (clearing session/cache)..."
|
||||||
# To restart a single tenant without restarting the global python processes,
|
|
||||||
# we can invalidate their cache or drop their sessions collection to sign everyone out
|
|
||||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||||
if [ -n "$APP_CONTAINER" ]; then
|
if [ -n "$APP_CONTAINER" ]; then
|
||||||
docker exec $APP_CONTAINER python3 -c "
|
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
|
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))
|
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||||
db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}']
|
db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}']
|
||||||
db.sessions.drop() # Force sign-out / session clear
|
db.sessions.drop()
|
||||||
print(f'Tenant {sys.argv[1]} session cache cleared. Tenant restarted.')
|
print(f'Tenant {sys.argv[1]} session cache cleared. Tenant restarted.')
|
||||||
" "$TENANT_ID"
|
" "$TENANT_ID"
|
||||||
echo "Tenant '$TENANT_ID' has been refreshed without impacting others."
|
echo "Tenant '$TENANT_ID' has been refreshed without impacting others."
|
||||||
@@ -833,12 +805,12 @@ PY
|
|||||||
;;
|
;;
|
||||||
|
|
||||||
module)
|
module)
|
||||||
|
TENANT_ID="${2:-}"
|
||||||
if [ -z "$TENANT_ID" ] || [ -z "${3:-}" ]; then
|
if [ -z "$TENANT_ID" ] || [ -z "${3:-}" ]; then
|
||||||
echo "Error: Usage: manage-tenant.sh module <tenant_id> <module_name>=<on|off> [...]"
|
echo "Error: Usage: manage-tenant.sh module <tenant_id> <module_name>=<on|off> [...]"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Pass all remaining arguments to python script
|
|
||||||
shift 2
|
shift 2
|
||||||
|
|
||||||
if python3 - "$CONFIG_FILE" "$TENANT_ID" "$@" <<'PY'
|
if python3 - "$CONFIG_FILE" "$TENANT_ID" "$@" <<'PY'
|
||||||
@@ -914,4 +886,4 @@ PY
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
Reference in New Issue
Block a user