Compare commits

...

18 Commits

Author SHA1 Message Date
Aiirondev_dev 3900059eb1 Configurierung des Slot amount generation 2026-06-26 19:13:30 +02:00
Aiirondev_dev 07b1cc5e79 Implementation of a multiple Clients per slot logic 2026-06-26 18:59:09 +02:00
Aiirondev_dev 6583f7a368 bug fix 2026-06-26 17:53:13 +02:00
Aiirondev_dev 8e11fbf969 addition of a csv export funktion for further Processing of the User 2026-06-26 17:41:51 +02:00
Aiirondev_dev 7d13dc264c further changes to also reflekt all relevant changes 2026-06-26 17:32:24 +02:00
Aiirondev_dev 480bd00a54 further itegration of the new view of the User 2026-06-26 17:15:43 +02:00
Aiirondev_dev fdf699363a adjustments to the user review of his appointments 2026-06-26 17:04:25 +02:00
Aiirondev_dev dd495a8b13 decent changes 2026-06-26 16:52:12 +02:00
Aiirondev_dev ca57961d4b slight start of adjustments 2026-06-26 16:43:21 +02:00
Aiirondev_dev 3923939387 slight fix of the unused field at the end of the List 2026-06-26 14:18:23 +02:00
Aiirondev_dev eb9c655398 Addition of the custom fields in the Terminplaner 2026-06-26 13:55:04 +02:00
Aiirondev_dev 4c2657218d slight adjustments, removal of unused descriptive material 2026-06-26 10:37:19 +02:00
Aiirondev_dev 875cfa01a3 Some changes to the decryption of the Borrowers for the decryption of the USer 2026-06-25 15:24:23 +02:00
Aiirondev_dev c0c61a41a3 few needet repairs tro the return of the upload item funktion to not return json but a redirect 2026-06-25 15:02:00 +02:00
Aiirondev_dev 9e74d0c16d Adjustmentws to prevent over engeneering fo extra routes 2026-06-25 09:44:03 +02:00
Aiirondev_dev c8818edf0b better documentation for error finding and slight adjustments for library_loan_admin _ensure_audit_indexes_once 2026-06-25 09:37:44 +02:00
Aiirondev_dev b270bc9367 another relevant change to password security for the salt that is generated for every user Indivisdualy, to fix the temporary approach from before 2026-06-24 19:41:22 +02:00
Aiirondev_dev e1c284fc40 additional changes to the generatio of the tenant 2026-06-24 17:24:34 +02:00
11 changed files with 723 additions and 333 deletions
+8
View File
@@ -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
+27 -21
View File
@@ -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', '')
+4 -2
View File
@@ -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 = (), clients_p_slot: int=1):
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,10 @@ 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 'clients_per_slot': clients_p_slot,
'slots_booked': [], # -> [(start_time, (names),(custom1, custom2,...)), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable
'Created': datetime.datetime.now(), 'Created': datetime.datetime.now(),
'LastUpdated': datetime.datetime.now() 'LastUpdated': datetime.datetime.now()
} }
+91 -107
View File
@@ -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).
Args: - Format für neue Hashes: v1$<salt_hex>$<hash_hex>
password (str): Password to hash
Returns:
str: Hexadecimal digest of the hashed password
""" """
hashed = hashlib.scrypt(password.encode(), salt=b'some_salt', n=16384, r=8, p=1) password_bytes = password.encode('utf-8') # Explizit UTF-8 für Plattformunabhängigkeit
return hashed.hex()
if salt is None:
# Neuer Benutzer / Passwortänderung -> Dynamischer Salt
random_salt = os.urandom(16)
hashed = hashlib.scrypt(password_bytes, salt=random_salt, n=16384, r=8, p=1)
return f"v1${random_salt.hex()}${hashed.hex()}"
else:
# Bestehender Benutzer (wird zur Verifizierung aufgerufen)
hashed = hashlib.scrypt(password_bytes, salt=salt, n=16384, r=8, p=1)
return hashed.hex()
def verify_password(provided_password, stored_password_string):
"""
Verifiziert ein Passwort gegen einen gespeicherten Hash-String.
Unterstützt das alte Format (statischer Salt) und das neue Format (v1$...).
"""
if not stored_password_string:
return False
# Überprüfung für das neue, sichere Format
if stored_password_string.startswith("v1$"):
try:
_, salt_hex, hash_hex = stored_password_string.split("$")
salt_bytes = bytes.fromhex(salt_hex)
# Berechne den Hash des eingegebenen Passworts mit dem extrahierten Salt
calculated_hash = hashing(provided_password, salt=salt_bytes)
# Timing-Attack-sicherer Vergleich
return hmac.compare_digest(calculated_hash, hash_hex)
except (ValueError, TypeError):
logger.error("Ungültiges Hash-Format in der Datenbank entdeckt.")
return False
else:
# Abwärtskompatibilität: Altes Format mit statischem Salt b'some_salt'
old_static_salt = b'some_salt'
calculated_hash = hashing(provided_password, salt=old_static_salt)
return hmac.compare_digest(calculated_hash, stored_password_string)
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):
+51 -15
View File
@@ -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 = (), client_per_slot: int=1) -> 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, clients_p_slot=client_per_slot)
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: tuple = ()):
try: try:
item = termin.get_item(id) item = termin.get_item(id)
if not item: if not item:
@@ -264,18 +269,32 @@ def book_slot(id, date_start_time, name):
if not isinstance(slots, list): if not isinstance(slots, list):
slots = [] slots = []
# 1. Check overall total booking capacity
capacity = int(item.get('slots', 0) or 0) capacity = int(item.get('slots', 0) or 0)
if capacity and len(slots) >= capacity: if capacity and len(slots) >= capacity:
return False return False
# 2. Prevent the exact same user from double-booking the exact same slot
for existing in slots: for existing in slots:
if isinstance(existing, (list, tuple)) and len(existing) >= 2: if isinstance(existing, (list, tuple)) and len(existing) >= 2:
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)) # 3. Check concurrent capacity for this specific time slot
clients_per_slot = int(item.get('clients_per_slot', 1) or 1)
bookings_at_time = sum(
1 for existing in slots
if isinstance(existing, (list, tuple)) and len(existing) > 0 and existing[0] == date_start_time
)
if bookings_at_time >= clients_per_slot:
return False
# Append the new booking successfully
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:
print(f"Error booking slot: {e}") print(f"Error booking slot: {e}")
return False return False
@@ -326,32 +345,46 @@ def get_available(id):
date_end = termin_range.get('date_end') date_end = termin_range.get('date_end')
time_span = termin_range.get('time_span', []) time_span = termin_range.get('time_span', [])
# Safe integer parsing without heavy try-except blocks
try: try:
total_slots = int(termin_range.get('slots', 0) or 0) total_slots = int(termin_range.get('slots', 0) or 0)
except Exception: except (ValueError, TypeError):
total_slots = 0 total_slots = 0
# Support both spellings gracefully
raw_length = termin_range.get('slot_length') or termin_range.get('slot_lenght') or 0
try: try:
slot_length = int(termin_range.get('slot_length') or termin_range.get('slot_lenght') or 0) slot_length = int(raw_length)
except Exception: except (ValueError, TypeError):
slot_length = termin_range.get('slot_length') or termin_range.get('slot_lenght') slot_length = raw_length
clients_per_slot = int(termin_range.get('clients_per_slot', 1) or 1)
booked = termin_range.get('slots_booked', []) or [] booked = termin_range.get('slots_booked', []) or []
# Normalize the bookings list safely
normalized = [] normalized = []
bookings_by_time = {} # Tracks how many people are in each specific time slot
for s in booked: for s in booked:
if isinstance(s, (list, tuple)) and len(s) >= 2: if isinstance(s, (list, tuple)) and len(s) >= 2:
normalized.append({'start': s[0], 'name': s[1]}) slot_time = s[0]
item = {'start': slot_time, 'name': s[1]}
elif isinstance(s, dict): elif isinstance(s, dict):
normalized.append(s) slot_time = s.get('start')
item = s
else: else:
normalized.append({'value': s}) slot_time = str(s)
item = {'value': s}
normalized.append(item)
# Count concurrent bookings per timestamp
if slot_time:
bookings_by_time[slot_time] = bookings_by_time.get(slot_time, 0) + 1
# Calculate remaining total capacity safely
slots_used = len(normalized) slots_used = len(normalized)
try: slots_left = max(0, total_slots - slots_used)
slots_left = max(0, int(total_slots) - slots_used)
except Exception:
slots_left = max(0, slots_used - slots_used)
return { return {
'date_start': date_start, 'date_start': date_start,
@@ -360,8 +393,10 @@ def get_available(id):
'slot_length': slot_length, 'slot_length': slot_length,
'slot_lenght': slot_length, 'slot_lenght': slot_length,
'slots_total': total_slots, 'slots_total': total_slots,
'clients_per_slot': clients_per_slot,
'slots_booked': normalized, 'slots_booked': normalized,
'slots_left': slots_left, 'slots_left': slots_left,
'bookings_by_time': bookings_by_time
} }
except Exception as e: except Exception as e:
print(f"Error getting available slots: {e}") print(f"Error getting available slots: {e}")
@@ -418,6 +453,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')),
} }
) )
+168 -40
View File
@@ -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()
custom_fields = appointment_item.get('custom_fields', [])
# Permissions check
can_view_booking_names = False can_view_booking_names = False
if current_user: if current_user:
try: try:
@@ -57,6 +124,7 @@ def client(appointment_id):
except Exception: except Exception:
can_view_booking_names = bool(current_user == appointment_owner) can_view_booking_names = bool(current_user == appointment_owner)
# Sanitize data for public/client view
available_for_view = dict(available) available_for_view = dict(available)
if not can_view_booking_names: if not can_view_booking_names:
sanitized_bookings = [] sanitized_bookings = []
@@ -70,31 +138,58 @@ 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): # Case 1: Admin/Owner cancels a booking
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')
current_slots = appointment_item.get('slots_booked', []) or []
# Keep everything EXCEPT the item targeted for deletion
updated_slots = [
slot for slot in current_slots
if not (isinstance(slot, (list, tuple)) and slot[0] == slot_time and slot[1] == client_name)
]
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))
# Case 2: Client books a slot
elif action == 'book':
start_daytime = request.form.get('start_day_time')
username = request.form.get('client_name')
custom_answers = tuple(request.form.getlist('custom_answers')) # Cast to tuple for DB safety
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. Eventuell ist der Slot bereits voll.', 'error')
return render_template( return render_template(
'termin_client.html', 'termin_client.html',
@@ -103,6 +198,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
) )
@@ -154,7 +251,7 @@ def delete_appointment(appointment_id):
@appoint_bp.route('/configure', methods=['GET', 'POST']) @appoint_bp.route('/configure', methods=['GET', 'POST'])
def configure(): def configure():
""" """
Route for authenticated persons to configure a new appointment for them Route for authenticated persons to configure a new appointment schedule
""" """
guard = _require_module_enabled() guard = _require_module_enabled()
if guard: if guard:
@@ -169,13 +266,18 @@ 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')
try:
clients_p_slot = int(request.form.get('clients_per_slot', 1))
except (ValueError, TypeError):
clients_p_slot = 1
# 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(
@@ -185,28 +287,54 @@ def configure():
email_service_enabled=cfg.EMAIL_ENABLED, email_service_enabled=cfg.EMAIL_ENABLED,
) )
# Variablen im Funktionsaufruf aktualisiert # Call the database service function (standardized to match your underlying code)
result = appointment_service.new(start, end, time, slots_amount, slot_length, session["username"], mail, note, calendar_enabled=add_to_calendar, title=title) inserted_id = appointment_service.add(
date_start=start,
date_end=end,
time_span=time,
slots=slots_amount,
slot_lenght=slot_length,
user=session["username"],
mail=mail,
note=note,
calendar_enabled=add_to_calendar,
title=title,
custom_fields=custom,
clients_p_slot=clients_p_slot
)
if not inserted_id:
flash('Fehler beim Erstellen des Terminplans.', 'error')
return redirect(url_for('terminplaner.configure'))
# Resolve the URL string here using Flask's native url_for instead of relying on the database layer
generated_link = url_for(
'terminplaner.client',
appointment_id=str(inserted_id),
tenant=_current_tenant_id() or None,
_external=True
)
flash('Der Terminplan wurde angelegt.', 'success') flash('Der Terminplan wurde angelegt.', 'success')
return render_template( return render_template(
'termin_configure.html', 'termin_configure.html',
school_periods=cfg.SCHOOL_PERIODS, school_periods=cfg.SCHOOL_PERIODS,
generated_link=result['link'], generated_link=generated_link,
calendar_link=result.get('calendar_link'), calendar_link=None, # Update with calendar service link generation if needed
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,
) )
elif request.method == "GET":
return render_template( return render_template(
'termin_configure.html', 'termin_configure.html',
school_periods=cfg.SCHOOL_PERIODS, school_periods=cfg.SCHOOL_PERIODS,
generated_link=None, generated_link=None,
calendar_link=None, calendar_link=None,
add_to_calendar=False, add_to_calendar=False,
email_service_enabled=cfg.EMAIL_ENABLED, email_service_enabled=cfg.EMAIL_ENABLED,
title=None, title=None,
) )
@appoint_bp.route('/calendar/<appointment_id>.ics', methods=['GET']) @appoint_bp.route('/calendar/<appointment_id>.ics', methods=['GET'])
+1 -4
View File
@@ -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>
+268 -113
View File
@@ -39,12 +39,18 @@
<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>
@@ -59,22 +65,23 @@
<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>
<div class="day-slider-wrap mb-3"> {% if can_view_booking_names %}
<div class="d-flex justify-content-between align-items-center gap-2 mb-2"> <!-- ADMIN / AUTOREN ANSICHT -->
<span class="small text-muted">Tag wählen</span> <div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
<strong id="day-slider-label" class="small"></strong> <div>
<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 class="bg-light p-3 rounded-3 mb-4">
<div class="row g-2 align-items-center">
<div class="col-12 col-md-8">
<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...">
</div>
<div class="col-12 col-md-4 d-flex align-items-end h-100 mt-md-4">
<button type="button" id="clear-filter-btn" class="btn btn-outline-secondary w-100 btn-lg">Filter zurücksetzen</button>
</div>
</div>
</div> </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> {% if available.slots_booked %}
<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>
<form id="client-booking-form" method="post" action="{{ url_for('terminplaner.client', appointment_id=appointment_id, tenant=tenant_id) }}" class="vstack gap-3"> <!-- 2. CLIENT NAME -->
<div> <td>
<label for="start_day_time" class="form-label fw-semibold">Gewünschter Zeitpunkt</label> <span class="fw-bold text-dark">{{ booking[1] }}</span>
<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> </td>
<div class="form-text">Klicken Sie auf einen freien Slot oder verschieben Sie den gewählten Block.</div>
<!-- 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>
<label for="client_name" class="form-label fw-semibold">Ihr Name</label> <div id="no-results-msg" class="text-center py-4 text-muted d-none">
<input type="text" id="client_name" name="client_name" class="form-control form-control-lg" placeholder="Vor- und Nachname" required> Keine Buchungen entsprechen Ihrem Filter.
</div> </div>
<div class="d-flex flex-column flex-sm-row gap-2 pt-2"> {% else %}
<button type="submit" class="btn btn-primary btn-lg">Termin buchen</button> <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,11 +271,58 @@
</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 () {
const available = {{ available|tojson }}; const available = {{ available|tojson }};
const appointmentId = {{ appointment_id|tojson }}; const appointmentId = {{ appointment_id|tojson }};
const canViewBookingNames = {{ can_view_booking_names|tojson }}; // Injected from Flask
const slider = document.getElementById('day-slider'); const slider = document.getElementById('day-slider');
const sliderLabel = document.getElementById('day-slider-label'); const sliderLabel = document.getElementById('day-slider-label');
const sliderWrap = slider ? slider.closest('.day-slider-wrap') : null; const sliderWrap = slider ? slider.closest('.day-slider-wrap') : null;
@@ -160,10 +336,10 @@
const confirmBookingWithIcsBtn = document.getElementById('confirm-booking-with-ics'); const confirmBookingWithIcsBtn = document.getElementById('confirm-booking-with-ics');
const modal = modalEl ? new bootstrap.Modal(modalEl) : null; const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
const slotLength = Number.parseInt(available.slot_lenght, 10) || 45; // Fallback for both spellings of slot length
const bookedStarts = new Set((available.slots_booked || []).map(function (entry) { const slotLength = Number.parseInt(available.slot_length || available.slot_lenght, 10) || 45;
return String(entry.start || '').trim(); const clientsPerSlot = Number.parseInt(available.clients_per_slot, 10) || 1;
}).filter(Boolean)); const bookingsByTime = available.bookings_by_time || {};
function formatDateForInput(dateObj) { function formatDateForInput(dateObj) {
const y = dateObj.getFullYear(); const y = dateObj.getFullYear();
@@ -227,6 +403,8 @@
return null; return null;
} }
const fullyBookedSlots = [];
function buildCandidateSlots() { function buildCandidateSlots() {
const slots = []; const slots = [];
const allowedDates = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || '')); const allowedDates = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
@@ -234,9 +412,7 @@
spans.forEach(function (entry) { spans.forEach(function (entry) {
const parsed = parseTimeSpanEntry(entry); const parsed = parseTimeSpanEntry(entry);
if (!parsed) { if (!parsed) return;
return;
}
const targetDates = parsed.date ? [parsed.date] : allowedDates; const targetDates = parsed.date ? [parsed.date] : allowedDates;
targetDates.forEach(function (date) { targetDates.forEach(function (date) {
@@ -249,10 +425,19 @@
let cursor = new Date(from); let cursor = new Date(from);
while (addMinutes(cursor, slotLength) <= to) { while (addMinutes(cursor, slotLength) <= to) {
const slotStart = formatDateForInput(cursor); const slotStart = formatDateForInput(cursor);
if (!bookedStarts.has(slotStart)) { const currentBookingsCount = bookingsByTime[slotStart] || 0;
// Check if the current individual timestamp still has capacity left
if (currentBookingsCount < clientsPerSlot) {
slots.push({ slots.push({
start: slotStart, start: slotStart,
end: formatDateForInput(addMinutes(cursor, slotLength)), end: formatDateForInput(addMinutes(cursor, slotLength)),
bookingsCount: currentBookingsCount
});
} else {
fullyBookedSlots.push({
start: slotStart,
end: formatDateForInput(addMinutes(cursor, slotLength))
}); });
} }
cursor = addMinutes(cursor, slotLength); cursor = addMinutes(cursor, slotLength);
@@ -267,27 +452,8 @@
const slotStartSet = new Set(candidateSlots.map(function (slot) { return slot.start; })); const slotStartSet = new Set(candidateSlots.map(function (slot) { return slot.start; }));
const allDays = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || '')); const allDays = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
// Show the requested UI time window from 08:15 to 20:00 and highlight available slots
let slotMinTime = '08:15:00'; let slotMinTime = '08:15:00';
let slotMaxTime = '20:00:00'; let slotMaxTime = '20:00:00';
if (candidateSlots.length > 0) {
const starts = candidateSlots.map(function (slot) {
return new Date(slot.start.replace(' ', 'T') + ':00');
}).filter(function (d) { return !Number.isNaN(d.getTime()); });
const ends = candidateSlots.map(function (slot) {
return new Date(slot.end.replace(' ', 'T') + ':00');
}).filter(function (d) { return !Number.isNaN(d.getTime()); });
// Keep the computed min/max for gap calculations below
var computedMinStart = null;
var computedMaxEnd = null;
if (starts.length > 0 && ends.length > 0) {
computedMinStart = starts[0];
computedMaxEnd = ends[0];
starts.forEach(function (d) { if (d < computedMinStart) computedMinStart = d; });
ends.forEach(function (d) { if (d > computedMaxEnd) computedMaxEnd = d; });
}
}
const visibleStart = allDays[0] || String(available.date_start || ''); const visibleStart = allDays[0] || String(available.date_start || '');
const visibleEndExclusive = allDays.length > 0 const visibleEndExclusive = allDays.length > 0
@@ -322,14 +488,8 @@
slotMinTime: slotMinTime, slotMinTime: slotMinTime,
slotMaxTime: slotMaxTime, slotMaxTime: slotMaxTime,
nowIndicator: true, nowIndicator: true,
validRange: { validRange: { start: visibleStart, end: visibleEndExclusive },
start: visibleStart, visibleRange: { start: visibleStart, end: visibleEndExclusive },
end: visibleEndExclusive,
},
visibleRange: {
start: visibleStart,
end: visibleEndExclusive,
},
headerToolbar: { headerToolbar: {
left: '', left: '',
center: 'title', center: 'title',
@@ -337,28 +497,22 @@
}, },
events: [], events: [],
eventDrop: function (info) { eventDrop: function (info) {
if (info.event.id !== 'selected-slot') { if (info.event.id !== 'selected-slot') return;
return;
}
const droppedStart = formatDateForInput(info.event.start); const droppedStart = formatDateForInput(info.event.start);
if (!slotStartSet.has(droppedStart)) { if (!slotStartSet.has(droppedStart)) {
info.revert(); info.revert();
window.alert('Dieser Zeitpunkt ist nicht als freier Slot verfügbar.'); window.alert('Dieser Zeitpunkt ist ausgebucht oder nicht verfügbar.');
return; return;
} }
applySelectedSlot(droppedStart); applySelectedSlot(droppedStart);
}, },
eventClick: function (info) { eventClick: function (info) {
const slotType = info.event.extendedProps ? info.event.extendedProps.slotType : ''; const slotType = info.event.extendedProps ? info.event.extendedProps.slotType : '';
if (slotType !== 'free') { if (slotType !== 'free') return;
return;
}
applySelectedSlot(info.event.extendedProps.slotStart || ''); applySelectedSlot(info.event.extendedProps.slotStart || '');
} }
}); });
// No background greying: show full calendar skeleton and only mark possible slots
function updateSliderLabel() { function updateSliderLabel() {
const dateList = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || '')); const dateList = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
const idx = Number.parseInt(slider.value, 10) || 0; const idx = Number.parseInt(slider.value, 10) || 0;
@@ -377,9 +531,7 @@
selectedEvent = null; selectedEvent = null;
} }
if (!selectedSlot) { if (!selectedSlot) return;
return;
}
const start = new Date(selectedSlot.replace(' ', 'T') + ':00'); const start = new Date(selectedSlot.replace(' ', 'T') + ':00');
const end = addMinutes(start, slotLength); const end = addMinutes(start, slotLength);
@@ -398,13 +550,9 @@
function getIcsDownloadUrl() { function getIcsDownloadUrl() {
const base = {{ url_for('terminplaner.client_slot_calendar_export', appointment_id=appointment_id, tenant=tenant_id)|tojson }}; const base = {{ url_for('terminplaner.client_slot_calendar_export', appointment_id=appointment_id, tenant=tenant_id)|tojson }};
const url = new URL(base, window.location.origin); const url = new URL(base, window.location.origin);
if (selectedSlot) { if (selectedSlot) url.searchParams.set('start', selectedSlot);
url.searchParams.set('start', selectedSlot);
}
const name = String(clientNameInput.value || '').trim(); const name = String(clientNameInput.value || '').trim();
if (name) { if (name) url.searchParams.set('name', name);
url.searchParams.set('name', name);
}
return url.toString(); return url.toString();
} }
@@ -415,14 +563,19 @@
} }
} }
// No background gaps: keep full skeleton/grid visible // Render Active/Available Slots with Live Seat Counts
// Add candidate free slots (blue)
candidateSlots.forEach(function (slot) { candidateSlots.forEach(function (slot) {
const start = new Date(slot.start.replace(' ', 'T') + ':00'); const start = new Date(slot.start.replace(' ', 'T') + ':00');
const end = new Date(slot.end.replace(' ', 'T') + ':00'); const end = new Date(slot.end.replace(' ', 'T') + ':00');
let displayTitle = 'Freier Slot';
if (clientsPerSlot > 1) {
const remainingSeats = clientsPerSlot - slot.bookingsCount;
displayTitle += ' (' + remainingSeats + '/' + clientsPerSlot + ' frei)';
}
calendar.addEvent({ calendar.addEvent({
title: 'Freier Slot', title: displayTitle,
start: start, start: start,
end: end, end: end,
color: '#0d6efd', color: '#0d6efd',
@@ -435,46 +588,54 @@
}); });
}); });
(available.slots_booked || []).forEach(function (booking) { // Client View Optimization: Render solid red events ONLY if a slot is 100% full
const startStr = String(booking.start || '').trim(); fullyBookedSlots.forEach(function (slot) {
if (!startStr) { const start = new Date(slot.start.replace(' ', 'T') + ':00');
return; const end = new Date(slot.end.replace(' ', 'T') + ':00');
}
const start = new Date(startStr.replace(' ', 'T') + ':00');
const end = addMinutes(start, slotLength);
calendar.addEvent({ calendar.addEvent({
title: 'Gebucht' + (booking.name ? ' - ' + booking.name : ''), title: 'Ausgebucht',
start: start, start: start,
end: end, end: end,
color: '#dc3545', color: '#dc3545',
editable: false, editable: false,
display: 'block', display: 'block'
}); });
}); });
// Admin Management View: Render detailed name blocks exclusively for management interaction
if (canViewBookingNames) {
(available.slots_booked || []).forEach(function (booking) {
const startStr = String(booking.start || '').trim();
if (!startStr) return;
const start = new Date(startStr.replace(' ', 'T') + ':00');
const end = addMinutes(start, slotLength);
calendar.addEvent({
title: 'Belegt: ' + (booking.name || 'Anonym'),
start: start,
end: end,
color: '#9e9e9e',
editable: false,
display: 'block',
});
});
}
// Event Listeners and Setup
form.addEventListener('submit', function (ev) { form.addEventListener('submit', function (ev) {
if (!selectedSlotInput.value) { if (!selectedSlotInput.value) {
ev.preventDefault(); ev.preventDefault();
window.alert('Bitte zuerst im Kalender einen freien Slot auswählen.'); window.alert('Bitte zuerst im Kalender einen freien Slot auswählen.');
return; return;
} }
if (allowImmediateSubmit) return;
if (allowImmediateSubmit) {
return;
}
ev.preventDefault(); ev.preventDefault();
if (modal) { if (modal) modal.show();
modal.show();
}
}); });
if (confirmBookingOnlyBtn) { if (confirmBookingOnlyBtn) {
confirmBookingOnlyBtn.addEventListener('click', function () { confirmBookingOnlyBtn.addEventListener('click', function () {
allowImmediateSubmit = true; allowImmediateSubmit = true;
if (modal) { if (modal) modal.hide();
modal.hide();
}
form.submit(); form.submit();
}); });
} }
@@ -482,13 +643,9 @@
if (confirmBookingWithIcsBtn) { if (confirmBookingWithIcsBtn) {
confirmBookingWithIcsBtn.addEventListener('click', function () { confirmBookingWithIcsBtn.addEventListener('click', function () {
const icsUrl = confirmBookingWithIcsBtn.getAttribute('data-ics-url') || getIcsDownloadUrl(); const icsUrl = confirmBookingWithIcsBtn.getAttribute('data-ics-url') || getIcsDownloadUrl();
if (icsUrl) { if (icsUrl) window.open(icsUrl, '_blank');
window.open(icsUrl, '_blank');
}
allowImmediateSubmit = true; allowImmediateSubmit = true;
if (modal) { if (modal) modal.hide();
modal.hide();
}
form.submit(); form.submit();
}); });
} }
@@ -509,10 +666,8 @@
slider.addEventListener('input', function () { slider.addEventListener('input', function () {
const idx = Number.parseInt(slider.value, 10) || 0; const idx = Number.parseInt(slider.value, 10) || 0;
updateSliderLabel(); updateSliderLabel();
if (allDays[idx]) { if (allDays[idx] && calendar.view.type === 'timeGridDay') {
if (calendar.view.type === 'timeGridDay') { calendar.gotoDate(allDays[idx]);
calendar.gotoDate(allDays[idx]);
}
} }
}); });
+75 -7
View File
@@ -70,6 +70,10 @@
<label for="slot_length" class="form-label fw-semibold">Slot-Länge in Minuten</label> <label for="slot_length" class="form-label fw-semibold">Slot-Länge in Minuten</label>
<input type="number" id="slot_length" name="slot_length" class="form-control form-control-lg" min="1" value="45"> <input type="number" id="slot_length" name="slot_length" class="form-control form-control-lg" min="1" value="45">
</div> </div>
<div>
<label class="form-label fw-semibold text-primary">Nutzer pro Slot</label>
<input type="number" id="clients_per_slot" name="clients_per_slot" value="1">
</div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<label class="form-label fw-semibold text-primary">Automatisch berechnete Slots</label> <label class="form-label fw-semibold text-primary">Automatisch berechnete Slots</label>
<div id="slots_amounts_display" class="form-control form-control-lg bg-primary-subtle text-primary fw-bold d-flex align-items-center">0</div> <div id="slots_amounts_display" class="form-control form-control-lg bg-primary-subtle text-primary fw-bold d-flex align-items-center">0</div>
@@ -77,6 +81,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>
@@ -139,6 +153,7 @@
const defaultPauseEndInput = document.getElementById('default_pause_end'); const defaultPauseEndInput = document.getElementById('default_pause_end');
const slotLengthInput = document.getElementById('slot_length'); const slotLengthInput = document.getElementById('slot_length');
const clientsperslot = document.getElementById('clients_per_slot')
const slotsAmountsInput = document.getElementById('slots_amounts'); const slotsAmountsInput = document.getElementById('slots_amounts');
const slotsAmountsDisplay = document.getElementById('slots_amounts_display'); // Neu: Anzeige-Element const slotsAmountsDisplay = document.getElementById('slots_amounts_display'); // Neu: Anzeige-Element
@@ -188,9 +203,21 @@
if (slotsAmountsDisplay) slotsAmountsDisplay.innerText = totalSlots + " Slots gesamt"; if (slotsAmountsDisplay) slotsAmountsDisplay.innerText = totalSlots + " Slots gesamt";
} }
// Helper utility in case your timeToMinutes handler doesn't catch empty strings safely
function safeTimeToMinutes(timeString) {
if (!timeString || typeof timeString !== 'string' || !timeString.includes(':')) {
return 0; // Return 0 instead of NaN for empty or missing values
}
const parts = timeString.split(':');
const hours = parseInt(parts[0], 10);
const minutes = parseInt(parts[1], 10);
if (isNaN(hours) || isNaN(minutes)) return 0;
return (hours * 60) + minutes;
}
// Berechnet die Slots basierend auf den konfigurierten Zeiten & Pausen // Berechnet die Slots basierend auf den konfigurierten Zeiten & Pausen
function calculateSlots() { function calculateSlots() {
if (!slotLengthInput || !slotsAmountsInput) return; if (!slotLengthInput || !slotsAmountsInput || !clientsperslot) return;
const slotLength = parseInt(slotLengthInput.value, 10); const slotLength = parseInt(slotLengthInput.value, 10);
if (isNaN(slotLength) || slotLength <= 0) { if (isNaN(slotLength) || slotLength <= 0) {
@@ -198,6 +225,9 @@
return; return;
} }
// FIX 1: Make sure we parse the input VALUE, defaulting to 1 if empty or invalid
const multiplier = parseInt(clientsperslot.value, 10) || 1;
let totalSlots = 0; let totalSlots = 0;
const rows = Array.from(daysContainer.querySelectorAll('[data-day-row]')); const rows = Array.from(daysContainer.querySelectorAll('[data-day-row]'));
@@ -209,12 +239,13 @@
if (!startTime || !endTime) return; if (!startTime || !endTime) return;
const startMins = timeToMinutes(startTime); // Using safety parsing to prevent missing values from generating NaN
const endMins = timeToMinutes(endTime); const startMins = safeTimeToMinutes(startTime);
const pauseStartMins = timeToMinutes(pauseStart); const endMins = safeTimeToMinutes(endTime);
const pauseEndMins = timeToMinutes(pauseEnd); const pauseStartMins = safeTimeToMinutes(pauseStart);
const pauseEndMins = safeTimeToMinutes(pauseEnd);
if (endMins <= startMins) return; // Ungültige Zeit if (isNaN(startMins) || isNaN(endMins) || endMins <= startMins) return;
// Wenn eine gültige Pause innerhalb der Start/Endzeit existiert // Wenn eine gültige Pause innerhalb der Start/Endzeit existiert
if (pauseStartMins > 0 && pauseEndMins > 0 && pauseStartMins < pauseEndMins && pauseStartMins > startMins && pauseStartMins < endMins) { if (pauseStartMins > 0 && pauseEndMins > 0 && pauseStartMins < pauseEndMins && pauseStartMins > startMins && pauseStartMins < endMins) {
@@ -234,7 +265,11 @@
} }
}); });
updateSlotsDisplay(totalSlots); // FIX 2: Multiply by our safely parsed input value scalar
const totalSlotscomplete = totalSlots * multiplier;
// Safety check: if anything went wrong anyway, fallback to 0 instead of displaying NaN
updateSlotsDisplay(isNaN(totalSlotscomplete) ? 0 : totalSlotscomplete);
} }
// Synchronisiert das Textfeld und fügt den Break-Cut hinzu // Synchronisiert das Textfeld und fügt den Break-Cut hinzu
@@ -351,6 +386,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);
+1 -1
View File
@@ -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>
+17 -11
View File
@@ -1,27 +1,28 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
IFS=$'\n\t' IFS=$'\n\t'
# Script to manage multitenant deployment
# Skript zur Verwaltung von Multitenant-Deployments # Allows adding, removing, and restarting tenants without downtime for others
if [ ! -f "docker-compose-multitenant.yml" ]; then if [ ! -f "docker-compose-multitenant.yml" ]; then
echo "Error: docker-compose-multitenant.yml not found." echo "Error: docker-compose-multitenant.yml not found."
exit 1 exit 1
fi fi
# Resolve script directory so config paths are deterministic even when called via sudo
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_FILE="$SCRIPT_DIR/config.json" CONFIG_FILE="$SCRIPT_DIR/config.json"
# === HILFSFUNKTIONEN ===
ensure_runtime_config_json() { ensure_runtime_config_json() {
local config_path="$CONFIG_FILE" local config_path backup_path
local backup_path config_path="$CONFIG_FILE"
if [ -d "$config_path" ]; then if [ -d "$config_path" ]; then
backup_path="${config_path}.dir.$(date +%Y%m%d-%H%M%S).bak" backup_path="${config_path}.dir.$(date +%Y%m%d-%H%M%S).bak"
mv "$config_path" "$backup_path" mv "$config_path" "$backup_path"
echo "Warning: moved unexpected directory $config_path to $backup_path" echo "Warning: moved unexpected directory $config_path to $backup_path"
fi fi
if [ ! -f "$config_path" ]; then if [ ! -f "$config_path" ]; then
cat > "$config_path" <<'EOF' cat > "$config_path" <<'EOF'
{ {
@@ -29,6 +30,7 @@ ensure_runtime_config_json() {
"tenants": {} "tenants": {}
} }
EOF EOF
echo "Created default config.json"
fi fi
} }
@@ -192,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
@@ -205,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.scrypt("admin123", salt=b"some_salt", n=16384, r=8, p=1).hex()
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,
@@ -241,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",
@@ -266,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() {