Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb9c655398 | |||
| 4c2657218d | |||
| 875cfa01a3 | |||
| c0c61a41a3 | |||
| 9e74d0c16d | |||
| c8818edf0b | |||
| b270bc9367 | |||
| e1c284fc40 | |||
| debd4dceb1 | |||
| db15df57c3 | |||
| bcfe2095d5 | |||
| 69ac6eca63 | |||
| 717e3ce15e | |||
| 007ae04bf3 | |||
| c0947c5cf1 | |||
| c1d2935ad6 | |||
| 9fde0b4b2a | |||
| da7b075cc4 | |||
| 347f258b8f | |||
| 2421f867ed | |||
| 921915e92f | |||
| a4b0866293 | |||
| e10d86da4b | |||
| f160f23e9c | |||
| ca272542de | |||
| 7df9b65389 | |||
| e4af76c9c1 | |||
| f5944bf090 | |||
| 4e68da2368 | |||
| 0d6aca4e8c | |||
| 2bb0cbe599 | |||
| 339487b4d4 | |||
| 3f90e2d4f1 | |||
| b238b52fbf | |||
| 545dd7783c | |||
| f4ce1cf3b4 | |||
| 7bd12bddaa | |||
| b8e23b94d2 | |||
| 06b32b4a3d | |||
| d24bd6ba56 | |||
| d1f48cf184 | |||
| 32fccd17ec | |||
| 01447220a0 | |||
| b3aeb289ef | |||
| a6b90a6a9d | |||
| 0a5d7dedea | |||
| 74e11b5370 | |||
| c0d6ad7b72 | |||
| 7da7152332 |
+1
-2
@@ -8,5 +8,4 @@ INVENTAR_APP_MEM_SWAP_LIMIT=768m
|
|||||||
INVENTAR_WORKERS=4
|
INVENTAR_WORKERS=4
|
||||||
INVENTAR_THREADS=2
|
INVENTAR_THREADS=2
|
||||||
INVENTAR_WORKER_TIMEOUT=30
|
INVENTAR_WORKER_TIMEOUT=30
|
||||||
INVENTAR_WORKER_CONNECTIONS=100
|
INVENTAR_WORKER_CONNECTIONS=100
|
||||||
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
|
working_dir: /app/Web
|
||||||
|
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "4", "--threads", "2", "--timeout", "30", "--graceful-timeout", "20", "--worker-connections", "100", "--max-requests", "1000", "--max-requests-jitter", "100", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||||
image: ghcr.io/aiirondev/legendary-octo-garbanzo:v0.7.42
|
image: ghcr.io/aiirondev/legendary-octo-garbanzo:v0.7.42
|
||||||
build: null
|
build: null
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+192
-29
@@ -25,6 +25,7 @@ from werkzeug.routing import BuildError
|
|||||||
from jinja2 import TemplateNotFound
|
from jinja2 import TemplateNotFound
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
# Ensure imports work regardless of whether gunicorn starts in /app or /app/Web.
|
# Ensure imports work regardless of whether gunicorn starts in /app or /app/Web.
|
||||||
_CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
_CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
@@ -730,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
|
||||||
|
|
||||||
@@ -1268,7 +1270,8 @@ def inject_version():
|
|||||||
'inventory_module_enabled': cfg.MODULES.is_enabled('inventory'),
|
'inventory_module_enabled': cfg.MODULES.is_enabled('inventory'),
|
||||||
'terminplan_module_enabled': cfg.MODULES.is_enabled('terminplan'),
|
'terminplan_module_enabled': cfg.MODULES.is_enabled('terminplan'),
|
||||||
'library_module_enabled': cfg.MODULES.is_enabled('library'),
|
'library_module_enabled': cfg.MODULES.is_enabled('library'),
|
||||||
'student_cards_module_enabled': cfg.MODULES.is_enabled('student_cards'),
|
'student_cards_module_enabled': cfg.MODULES.is_enabled('student_cards'),#
|
||||||
|
'mail_module_enabled': cfg.MODULES.is_enabled('mail'),
|
||||||
'is_admin': is_admin,
|
'is_admin': is_admin,
|
||||||
'unread_notification_count': unread_notification_count,
|
'unread_notification_count': unread_notification_count,
|
||||||
'current_permissions': current_permissions,
|
'current_permissions': current_permissions,
|
||||||
@@ -1734,7 +1737,6 @@ def is_valid_isbn13(isbn13):
|
|||||||
check_digit = (10 - (checksum % 10)) % 10
|
check_digit = (10 - (checksum % 10)) % 10
|
||||||
return check_digit == int(isbn13[12])
|
return check_digit == int(isbn13[12])
|
||||||
|
|
||||||
|
|
||||||
def normalize_and_validate_isbn(isbn_raw):
|
def normalize_and_validate_isbn(isbn_raw):
|
||||||
"""Normalize ISBN and return a valid canonical ISBN-13/10 or empty string."""
|
"""Normalize ISBN and return a valid canonical ISBN-13/10 or empty string."""
|
||||||
isbn = normalize_isbn(isbn_raw)
|
isbn = normalize_isbn(isbn_raw)
|
||||||
@@ -1994,7 +1996,7 @@ def _upload_student_cards_excel():
|
|||||||
synonyms = {
|
synonyms = {
|
||||||
'ausweis_id': ['ausweis_id', 'ausweisid', 'ausweis-id', 'karte', 'kartennummer', 'card_id', 'id'],
|
'ausweis_id': ['ausweis_id', 'ausweisid', 'ausweis-id', 'karte', 'kartennummer', 'card_id', 'id'],
|
||||||
'student_name': ['student_name', 'schuelername', 'schülername', 'schueler', 'schüler', 'name', 'vollname', 'vorname_nachname', 'nachname_vorname'],
|
'student_name': ['student_name', 'schuelername', 'schülername', 'schueler', 'schüler', 'name', 'vollname', 'vorname_nachname', 'nachname_vorname'],
|
||||||
'first_name': ['vorname', 'first_name', 'firstname'],
|
'first_name': ['vorname', 'first_name', 'firstname', 'rufname'],
|
||||||
'last_name': ['nachname', 'last_name', 'lastname'],
|
'last_name': ['nachname', 'last_name', 'lastname'],
|
||||||
'class_name': ['klasse', 'class', 'class_name', 'jahrgang', 'jahrgangsstufe', 'stufe', 'gruppe', 'asv_klasse'],
|
'class_name': ['klasse', 'class', 'class_name', 'jahrgang', 'jahrgangsstufe', 'stufe', 'gruppe', 'asv_klasse'],
|
||||||
'notes': ['notizen', 'notes', 'bemerkungen', 'bemerkung', 'hinweis', 'hinweise'],
|
'notes': ['notizen', 'notes', 'bemerkungen', 'bemerkung', 'hinweis', 'hinweise'],
|
||||||
@@ -2885,6 +2887,7 @@ def home():
|
|||||||
username=session['username'],
|
username=session['username'],
|
||||||
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
||||||
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
||||||
|
mail_module_enabled=cfg.MODULES.is_enabled('mail'),
|
||||||
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
|
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
|
||||||
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS,
|
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS,
|
||||||
open_item=request.args.get('open_item')
|
open_item=request.args.get('open_item')
|
||||||
@@ -2927,6 +2930,7 @@ def home_admin():
|
|||||||
username=session['username'],
|
username=session['username'],
|
||||||
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
||||||
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
||||||
|
mail_module_enabled=cfg.MODULES.is_enabled('mail'),
|
||||||
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
|
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
|
||||||
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS,
|
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS,
|
||||||
school_info=_get_school_info_for_export(),
|
school_info=_get_school_info_for_export(),
|
||||||
@@ -2947,6 +2951,7 @@ def tutorial_page():
|
|||||||
is_admin=us.check_admin(session['username']),
|
is_admin=us.check_admin(session['username']),
|
||||||
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
||||||
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
||||||
|
mail_module_enabled=cfg.MODULES.is_enabled('mail'),
|
||||||
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
|
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
|
||||||
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS
|
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS
|
||||||
)
|
)
|
||||||
@@ -3297,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 ''
|
||||||
@@ -3816,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'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -4119,10 +4130,13 @@ def student_card_barcode_download():
|
|||||||
c.setLineWidth(2)
|
c.setLineWidth(2)
|
||||||
c.line(x_pos, y_pos - 10*mm, x_pos + card_width, y_pos - 10*mm)
|
c.line(x_pos, y_pos - 10*mm, x_pos + card_width, y_pos - 10*mm)
|
||||||
|
|
||||||
|
|
||||||
|
school_name = cfg.get_school_info()
|
||||||
|
school_name = school_name["name"]
|
||||||
# "SCHÜLERAUSWEIS" text in header
|
# "SCHÜLERAUSWEIS" text in header
|
||||||
c.setFont("Helvetica-Bold", 9)
|
c.setFont("Helvetica-Bold", 9)
|
||||||
c.setFillColor(white)
|
c.setFillColor(white)
|
||||||
c.drawString(x_pos + 3*mm, y_pos - 6.5*mm, "SCHÜLERAUSWEIS")
|
c.drawString(x_pos + 3*mm, y_pos - 6.5*mm, f"SCHÜLERAUSWEIS - {str(school_name)}")
|
||||||
|
|
||||||
# Student name - large and bold
|
# Student name - large and bold
|
||||||
c.setFillColor(text_dark)
|
c.setFillColor(text_dark)
|
||||||
@@ -4146,7 +4160,11 @@ def student_card_barcode_download():
|
|||||||
c.setFillColor(text_dark)
|
c.setFillColor(text_dark)
|
||||||
c.setFont("Helvetica-Bold", 9)
|
c.setFont("Helvetica-Bold", 9)
|
||||||
c.drawString(x_pos + 3*mm, y_pos - 28*mm, card['Klasse'])
|
c.drawString(x_pos + 3*mm, y_pos - 28*mm, card['Klasse'])
|
||||||
|
c.drawString(x_pos + 3*mm, y_pos - 31*mm, "-")
|
||||||
|
c.drawString(x_pos + 3*mm, y_pos - 34*mm, "-")
|
||||||
|
c.drawString(x_pos + 3*mm, y_pos - 37*mm, "-")
|
||||||
|
c.drawString(x_pos + 3*mm, y_pos - 40*mm, "-")
|
||||||
|
|
||||||
# Right barcode section with border highlight
|
# Right barcode section with border highlight
|
||||||
barcode_x_start = x_pos + info_width + 1*mm
|
barcode_x_start = x_pos + info_width + 1*mm
|
||||||
c.setFillColor(accent_color)
|
c.setFillColor(accent_color)
|
||||||
@@ -4286,10 +4304,12 @@ def student_card_single_barcode_download(card_id):
|
|||||||
c.setLineWidth(2.5)
|
c.setLineWidth(2.5)
|
||||||
c.line(x_pos, y_pos - 10*mm, x_pos + card_width, y_pos - 10*mm)
|
c.line(x_pos, y_pos - 10*mm, x_pos + card_width, y_pos - 10*mm)
|
||||||
|
|
||||||
|
school_name = cfg.get_school_info()
|
||||||
|
school_name = school_name["name"]
|
||||||
# "SCHÜLERAUSWEIS" text in header
|
# "SCHÜLERAUSWEIS" text in header
|
||||||
c.setFont("Helvetica-Bold", 11)
|
c.setFont("Helvetica-Bold", 11)
|
||||||
c.setFillColor(white)
|
c.setFillColor(white)
|
||||||
c.drawString(x_pos + 4*mm, y_pos - 6.5*mm, "SCHÜLERAUSWEIS")
|
c.drawString(x_pos + 4*mm, y_pos - 6.5*mm, f"SCHÜLERAUSWEIS - {str(school_name)}")
|
||||||
|
|
||||||
# Student name - large and prominent
|
# Student name - large and prominent
|
||||||
c.setFillColor(text_dark)
|
c.setFillColor(text_dark)
|
||||||
@@ -5060,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:
|
||||||
@@ -5073,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', '')
|
||||||
@@ -5102,6 +5127,7 @@ def upload_item():
|
|||||||
upload_mode = sanitize_form_value(request.form.get('upload_mode', 'item'))
|
upload_mode = sanitize_form_value(request.form.get('upload_mode', 'item'))
|
||||||
individual_codes_raw = sanitize_form_value(request.form.get('individual_codes', ''))
|
individual_codes_raw = sanitize_form_value(request.form.get('individual_codes', ''))
|
||||||
item_count_raw = sanitize_form_value(request.form.get('item_count', '1'))
|
item_count_raw = sanitize_form_value(request.form.get('item_count', '1'))
|
||||||
|
item_type_input = sanitize_form_value(request.form.get('item_type_input', ''))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
item_count = int(item_count_raw) if item_count_raw else 1
|
item_count = int(item_count_raw) if item_count_raw else 1
|
||||||
@@ -5181,6 +5207,8 @@ def upload_item():
|
|||||||
item_isbn = isbn_raw
|
item_isbn = isbn_raw
|
||||||
if upload_mode == 'library':
|
if upload_mode == 'library':
|
||||||
item_type = 'book'
|
item_type = 'book'
|
||||||
|
if item_type_input != "":
|
||||||
|
item_type = item_type_input
|
||||||
|
|
||||||
if upload_mode == 'library':
|
if upload_mode == 'library':
|
||||||
if not cfg.MODULES.is_enabled('library'):
|
if not cfg.MODULES.is_enabled('library'):
|
||||||
@@ -6027,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:
|
||||||
@@ -7980,7 +7995,8 @@ def admin_borrowings():
|
|||||||
'admin_borrowings.html',
|
'admin_borrowings.html',
|
||||||
entries=entries,
|
entries=entries,
|
||||||
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
||||||
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards')
|
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
||||||
|
mail_module_enabled=cfg.MODULES.is_enabled('mail')
|
||||||
)
|
)
|
||||||
|
|
||||||
"""-----------------------------------------------------------Audit Routes-------------------------------------------------------"""
|
"""-----------------------------------------------------------Audit Routes-------------------------------------------------------"""
|
||||||
@@ -8043,6 +8059,7 @@ def admin_audit_dashboard():
|
|||||||
audit_rows=audit_rows,
|
audit_rows=audit_rows,
|
||||||
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
||||||
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
||||||
|
mail_module_enabled=cfg.MODULES.is_enabled('mail'),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
app.logger.error(f"Error loading audit dashboard: {exc}")
|
app.logger.error(f"Error loading audit dashboard: {exc}")
|
||||||
@@ -8357,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', '')
|
||||||
|
|
||||||
@@ -8858,6 +8882,7 @@ def library_item_invoices(item_id):
|
|||||||
invoices=entries,
|
invoices=entries,
|
||||||
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
||||||
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
||||||
|
mail_module_enabled=cfg.MODULES.is_enabled('mail')
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error(f"Error loading invoice history for item {item_id}: {e}")
|
app.logger.error(f"Error loading invoice history for item {item_id}: {e}")
|
||||||
@@ -9532,6 +9557,134 @@ def _fetch_from_open_library(clean_isbn):
|
|||||||
print(f"OpenLibrary Search error: {e}")
|
print(f"OpenLibrary Search error: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _fetch_from_isbn_de(clean_isbn):
|
||||||
|
"""
|
||||||
|
Source 4: isbn.de (Maßgeschneidertes Scraping basierend auf realem HTML)
|
||||||
|
Extrahiert alle Buchdaten inklusive Preise aus den Meta-Tags und der Sidebar.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
url = f"https://www.isbn.de/buch/{clean_isbn}"
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.get(url, headers=headers, timeout=5)
|
||||||
|
if response.status_code != 200:
|
||||||
|
return None
|
||||||
|
|
||||||
|
soup = BeautifulSoup(response.text, 'html.parser')
|
||||||
|
|
||||||
|
# 1. Titel aus Meta-Tags extrahieren (extrem zuverlässig)
|
||||||
|
title_meta = soup.find('meta', property='og:title')
|
||||||
|
title = title_meta.get('content', '').strip() if title_meta else None
|
||||||
|
if not title:
|
||||||
|
h1_elem = soup.find('h1')
|
||||||
|
title = h1_elem.text.strip() if h1_elem else "Unknown Title"
|
||||||
|
|
||||||
|
# Falls Fehler- oder Suchseite
|
||||||
|
if "nicht gefunden" in title.lower() or "suche" in title.lower():
|
||||||
|
return None
|
||||||
|
|
||||||
|
# --- Hilfsfunktion zum Parsen der .infotab-Sidebar-Struktur ---
|
||||||
|
def get_sidebar_value(keyword):
|
||||||
|
infotab = soup.find(class_='infotab')
|
||||||
|
if infotab:
|
||||||
|
for d in infotab.find_all('div'):
|
||||||
|
if d.text.strip().lower() == keyword.lower():
|
||||||
|
parent = d.parent
|
||||||
|
# Label vom Gesamttext abziehen, um nur den Wert zu erhalten
|
||||||
|
return parent.text.replace(d.text, '', 1).strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 2. Verlag holen
|
||||||
|
publisher = get_sidebar_value('verlag')
|
||||||
|
if not publisher:
|
||||||
|
publisher = "Unknown Publisher"
|
||||||
|
|
||||||
|
# 3. Erscheinungsdatum (Aus Meta-Tag oder Sidebar)
|
||||||
|
date_meta = soup.find('meta', property='og:book:release_date')
|
||||||
|
if date_meta and date_meta.get('content'):
|
||||||
|
published_date = date_meta.get('content', '').strip()
|
||||||
|
else:
|
||||||
|
published_date = get_sidebar_value('erschienen am')
|
||||||
|
|
||||||
|
if not published_date:
|
||||||
|
published_date = "Unknown Date"
|
||||||
|
|
||||||
|
# 4. Autor (Da Schulbuch-Workbooks oft keinen Einzelautor haben, kluger Fallback)
|
||||||
|
author_meta = soup.find('meta', property='og:book:author')
|
||||||
|
author = author_meta.get('content', '').strip() if author_meta else ""
|
||||||
|
if not author:
|
||||||
|
author = get_sidebar_value('autor') or get_sidebar_value('herausgeber')
|
||||||
|
|
||||||
|
if not author:
|
||||||
|
author = f"{publisher} Redaktion" if publisher != "Unknown Publisher" else "Unknown Author"
|
||||||
|
|
||||||
|
# 5. Seitenanzahl
|
||||||
|
page_count = get_sidebar_value('seiten') or get_sidebar_value('umfang')
|
||||||
|
if page_count:
|
||||||
|
match = re.search(r'\d+', page_count)
|
||||||
|
page_count = match.group(0) if match else "Unknown"
|
||||||
|
else:
|
||||||
|
page_count = "Unknown"
|
||||||
|
|
||||||
|
# 6. Beschreibung aus ID 'bookdesc' holen und Whitespace-Fluss säubern
|
||||||
|
description = "Keine Beschreibung verfügbar"
|
||||||
|
desc_div = soup.find(id='bookdesc')
|
||||||
|
if desc_div:
|
||||||
|
# Holt den Text inklusive aller Listenpunkte (li) und formatiert ihn sauber
|
||||||
|
description = " ".join(desc_div.text.split())
|
||||||
|
|
||||||
|
# 7. Cover-Bild (Nutzt hochauflösenden Link aus den Metas)
|
||||||
|
thumbnail = ""
|
||||||
|
img_meta = soup.find('meta', property='og:image')
|
||||||
|
if img_meta:
|
||||||
|
thumbnail = img_meta.get('content', '').strip()
|
||||||
|
else:
|
||||||
|
img_tag = soup.find('img', id='ISBNcover')
|
||||||
|
if img_tag:
|
||||||
|
thumbnail = img_tag.get('data-big') or img_tag.get('src')
|
||||||
|
|
||||||
|
if thumbnail and thumbnail.startswith('/'):
|
||||||
|
thumbnail = "https://www.isbn.de" + thumbnail
|
||||||
|
|
||||||
|
# 8. PREIS EXTRAHIEREN (Neu integriert)
|
||||||
|
price = None
|
||||||
|
# Option A: Aus dem standardisierten Meta-Tag extrahieren (Ergibt z.B. 12.25)
|
||||||
|
price_meta = soup.find('meta', property='product:price:amount')
|
||||||
|
if price_meta and price_meta.get('content'):
|
||||||
|
try:
|
||||||
|
price = float(price_meta.get('content').strip())
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Option B: Fallback über die Infotab-Sidebar (Falls Meta fehlt, filtert "12,25 €*" zu Float)
|
||||||
|
if price is None:
|
||||||
|
price_str = get_sidebar_value('preis')
|
||||||
|
if price_str:
|
||||||
|
# Findet Zahlen wie "12,25" oder "12.25"
|
||||||
|
match = re.search(r'\d+([.,]\d+)?', price_str)
|
||||||
|
if match:
|
||||||
|
try:
|
||||||
|
price = float(match.group(0).replace(',', '.'))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": title,
|
||||||
|
"authors": author,
|
||||||
|
"publisher": publisher,
|
||||||
|
"publishedDate": published_date,
|
||||||
|
"description": description,
|
||||||
|
"pageCount": page_count,
|
||||||
|
"price": price, # Gibt nun z.B. 12.25 als float zurück
|
||||||
|
"thumbnail": thumbnail,
|
||||||
|
"source": "isbn-de"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"isbn.de Scraping error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
@app.route('/fetch_book_info/<isbn>')
|
@app.route('/fetch_book_info/<isbn>')
|
||||||
def fetch_book_info(isbn):
|
def fetch_book_info(isbn):
|
||||||
"""
|
"""
|
||||||
@@ -9555,6 +9708,7 @@ def fetch_book_info(isbn):
|
|||||||
providers = [
|
providers = [
|
||||||
_fetch_from_google_books,
|
_fetch_from_google_books,
|
||||||
_fetch_from_lobid_germany,
|
_fetch_from_lobid_germany,
|
||||||
|
_fetch_from_isbn_de,
|
||||||
_fetch_from_open_library
|
_fetch_from_open_library
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -10123,10 +10277,18 @@ def notifications_unread_status():
|
|||||||
@app.route('/admin/damaged_items')
|
@app.route('/admin/damaged_items')
|
||||||
def admin_damaged_items():
|
def admin_damaged_items():
|
||||||
"""Dedicated admin management window for damaged items."""
|
"""Dedicated admin management window for damaged items."""
|
||||||
if 'username' not in session or not us.check_admin(session['username']):
|
if 'username' not in session:
|
||||||
flash('Administratorrechte erforderlich.', 'error')
|
flash('Administratorrechte erforderlich.', 'error')
|
||||||
return redirect(url_for('login'))
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
'''
|
||||||
|
permissions = _get_current_user_permissions()
|
||||||
|
if not _action_access_allowed(permissions, 'can_manage_settings'):
|
||||||
|
flash('Sie haben keine Berechtigung, die Schulstammdaten zu ändern.', 'error')
|
||||||
|
if cfg.MODULES.is_enabled('library'):
|
||||||
|
return redirect(url_for('library_admin'))
|
||||||
|
'''
|
||||||
|
|
||||||
client = None
|
client = None
|
||||||
try:
|
try:
|
||||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||||
@@ -10192,6 +10354,7 @@ def admin_damaged_items():
|
|||||||
damaged_items=damaged_rows,
|
damaged_items=damaged_rows,
|
||||||
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
||||||
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
||||||
|
mail_module_enabled=cfg.MODULES.is_enabled('mail')
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
app.logger.error(f"Error loading damaged-items admin view: {exc}")
|
app.logger.error(f"Error loading damaged-items admin view: {exc}")
|
||||||
|
|||||||
@@ -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):
|
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)
|
||||||
@@ -53,8 +53,10 @@ def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
|
|||||||
'user': user,
|
'user': user,
|
||||||
'mail': mail,
|
'mail': mail,
|
||||||
'note': note,
|
'note': note,
|
||||||
|
'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
-105
@@ -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,103 +432,84 @@ def check_password_strength(password):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def hashing(password):
|
def hashing(password, salt=None):
|
||||||
"""
|
"""
|
||||||
Hash a password using SHA-512.
|
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):
|
||||||
"""
|
"""
|
||||||
return hashlib.sha512(password.encode()).hexdigest()
|
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:
|
||||||
@@ -556,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):
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from modules.module_registry import ModuleRegistry as mr
|
||||||
import smtplib
|
import smtplib
|
||||||
|
|
||||||
import Web.modules.database.settings as cfg
|
import Web.modules.database.settings as cfg
|
||||||
@@ -27,21 +28,24 @@ def send(email: list, subject: str, note: str, sender: str) -> bool:
|
|||||||
Output:
|
Output:
|
||||||
- bool: true if the sending worked and false if it didnt
|
- bool: true if the sending worked and false if it didnt
|
||||||
"""
|
"""
|
||||||
msg = MIMEMultipart()
|
if not mr.registry.is_enabled('mail'):
|
||||||
msg['Subject'] = subject
|
|
||||||
msg['From'] = sender or cfg.EMAIL_FROM_ADDRESS or cfg.EMAIL_USERNAME
|
|
||||||
msg['To'] = ', '.join(email) if isinstance(email, (list, tuple)) else str(email)
|
|
||||||
msg.attach(MIMEText(note))
|
|
||||||
smtp = None
|
|
||||||
try:
|
|
||||||
smtp = _build_smtp_client()
|
|
||||||
smtp.sendmail(from_addr=msg['From'], to_addrs=email, msg=msg.as_string())
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
return False
|
return False
|
||||||
finally:
|
else:
|
||||||
|
msg = MIMEMultipart()
|
||||||
|
msg['Subject'] = subject
|
||||||
|
msg['From'] = sender or cfg.EMAIL_FROM_ADDRESS or cfg.EMAIL_USERNAME
|
||||||
|
msg['To'] = ', '.join(email) if isinstance(email, (list, tuple)) else str(email)
|
||||||
|
msg.attach(MIMEText(note))
|
||||||
|
smtp = None
|
||||||
try:
|
try:
|
||||||
if smtp:
|
smtp = _build_smtp_client()
|
||||||
smtp.quit()
|
smtp.sendmail(from_addr=msg['From'], to_addrs=email, msg=msg.as_string())
|
||||||
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
return False
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if smtp:
|
||||||
|
smtp.quit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -82,6 +82,7 @@ def build_calendar_ics(appointment_id: str) -> str | None:
|
|||||||
time_span = item.get('time_span', []) or []
|
time_span = item.get('time_span', []) or []
|
||||||
creator = item.get('user', 'Terminplaner')
|
creator = item.get('user', 'Terminplaner')
|
||||||
note = item.get('note', '') or ''
|
note = item.get('note', '') or ''
|
||||||
|
titel = item.get('title', '') or ''
|
||||||
tenant_id = _current_tenant_id()
|
tenant_id = _current_tenant_id()
|
||||||
try:
|
try:
|
||||||
link = url_for('terminplaner.client', appointment_id=str(appointment_id), tenant=tenant_id or None, _external=True)
|
link = url_for('terminplaner.client', appointment_id=str(appointment_id), tenant=tenant_id or None, _external=True)
|
||||||
@@ -108,6 +109,8 @@ def build_calendar_ics(appointment_id: str) -> str | None:
|
|||||||
description_lines.append('Zeitfenster: ' + '; '.join(str(entry) for entry in time_span))
|
description_lines.append('Zeitfenster: ' + '; '.join(str(entry) for entry in time_span))
|
||||||
if note:
|
if note:
|
||||||
description_lines.append('Notiz: ' + str(note))
|
description_lines.append('Notiz: ' + str(note))
|
||||||
|
if titel:
|
||||||
|
description_lines.append('Titel: ' + str(titel))
|
||||||
|
|
||||||
ics_lines = [
|
ics_lines = [
|
||||||
'BEGIN:VCALENDAR',
|
'BEGIN:VCALENDAR',
|
||||||
@@ -123,6 +126,7 @@ def build_calendar_ics(appointment_id: str) -> str | None:
|
|||||||
f'URL:{_escape_ics_text(link)}',
|
f'URL:{_escape_ics_text(link)}',
|
||||||
f'DTSTART;VALUE=DATE:{_format_ics_date(start_date)}',
|
f'DTSTART;VALUE=DATE:{_format_ics_date(start_date)}',
|
||||||
f'DTEND;VALUE=DATE:{_format_ics_date(end_date + timedelta(days=1))}',
|
f'DTEND;VALUE=DATE:{_format_ics_date(end_date + timedelta(days=1))}',
|
||||||
|
f'Titel:{_escape_ics_text(titel)}',
|
||||||
'END:VEVENT',
|
'END:VEVENT',
|
||||||
'END:VCALENDAR',
|
'END:VCALENDAR',
|
||||||
'',
|
'',
|
||||||
@@ -142,9 +146,10 @@ def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
slot_minutes = int(item.get('slot_lenght') or 0)
|
slot_minutes = int(item.get('slot_length') or item.get('slot_lenght') or 0)
|
||||||
except Exception:
|
except Exception:
|
||||||
slot_minutes = 0
|
slot_minutes = 0
|
||||||
|
|
||||||
if slot_minutes <= 0:
|
if slot_minutes <= 0:
|
||||||
slot_minutes = 45
|
slot_minutes = 45
|
||||||
|
|
||||||
@@ -159,7 +164,7 @@ def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str
|
|||||||
if tenant_id:
|
if tenant_id:
|
||||||
link += f'?tenant={tenant_id}'
|
link += f'?tenant={tenant_id}'
|
||||||
|
|
||||||
title_name = str(client_name or '').strip() or 'Termin'
|
title_name = item.get('title') or f"Terminbuchung mit {client_name}" if client_name else "Terminbuchung"
|
||||||
summary = f"{title_name} - Terminbuchung"
|
summary = f"{title_name} - Terminbuchung"
|
||||||
description_lines = [
|
description_lines = [
|
||||||
f"Buchungslink: {link}",
|
f"Buchungslink: {link}",
|
||||||
@@ -185,6 +190,7 @@ def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str
|
|||||||
f'URL:{_escape_ics_text(link)}',
|
f'URL:{_escape_ics_text(link)}',
|
||||||
f'DTSTART:{dt_start}',
|
f'DTSTART:{dt_start}',
|
||||||
f'DTEND:{dt_end}',
|
f'DTEND:{dt_end}',
|
||||||
|
f'Titel:{_escape_ics_text(summary)}',
|
||||||
'END:VEVENT',
|
'END:VEVENT',
|
||||||
'END:VCALENDAR',
|
'END:VCALENDAR',
|
||||||
'',
|
'',
|
||||||
@@ -192,23 +198,24 @@ 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: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False) -> 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
|
||||||
|
|
||||||
Input:
|
|
||||||
- date_start: start of the time frae area
|
|
||||||
- date_end: end of the time frame area
|
|
||||||
- time_span: Time window for the days as a list [(first day Time Frame), (second day Time frame), (third etc.)]
|
|
||||||
- slots: amount of slots that are available
|
|
||||||
- slot_lenght: the lenght of a slot in minutes
|
|
||||||
|
|
||||||
Output:
|
|
||||||
- link: The link for the user to send to the clients
|
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
|
slots_int = int(slots)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
slots_int = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
slot_length_int = int(slot_length)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
slot_length_int = 45
|
||||||
|
|
||||||
normalized_time_span = _normalize_time_span(time_span)
|
normalized_time_span = _normalize_time_span(time_span)
|
||||||
normalized_mail = _normalize_mail_list(mail)
|
normalized_mail = _normalize_mail_list(mail or [])
|
||||||
id = termin.add(date_start, date_end, normalized_time_span, slots, slot_lenght, user, normalized_mail, note, calendar_enabled=calendar_enabled)
|
|
||||||
|
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()
|
||||||
|
|
||||||
@@ -219,9 +226,11 @@ def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
|
|||||||
link = host + "/terminplaner/client/" + id_str
|
link = host + "/terminplaner/client/" + id_str
|
||||||
if tenant_id:
|
if tenant_id:
|
||||||
link += f"?tenant={tenant_id}"
|
link += f"?tenant={tenant_id}"
|
||||||
subject = f"Terminanfrage von {user}"
|
|
||||||
|
subject = f"{title} - Bitte Termin vereinbaren" if title else f"Terminanfrage von {user} - Bitte Termin vereinbaren"
|
||||||
note_link = note + f"Bitte klicken sie auf den folgenden Link um einen Termin zu vereinbaren: {link}"
|
note_link = note + f"Bitte klicken sie auf den folgenden Link um einen Termin zu vereinbaren: {link}"
|
||||||
calendar_link = None
|
calendar_link = None
|
||||||
|
|
||||||
if calendar_enabled:
|
if calendar_enabled:
|
||||||
try:
|
try:
|
||||||
calendar_link = url_for('terminplaner.calendar_export', appointment_id=id_str, tenant=tenant_id or None, _external=True)
|
calendar_link = url_for('terminplaner.calendar_export', appointment_id=id_str, tenant=tenant_id or None, _external=True)
|
||||||
@@ -245,20 +254,8 @@ def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def book_slot(id, date_start_time, name):
|
def book_slot(id, date_start_time, name, custom:list=()):
|
||||||
"""
|
|
||||||
Updates slot for the booking per a id
|
|
||||||
|
|
||||||
Input:
|
|
||||||
- id: the id is the id you get from the
|
|
||||||
- date_start_time: the date of the booking that was selected with date and time
|
|
||||||
- name: name that the client gave himself
|
|
||||||
|
|
||||||
Output:
|
|
||||||
- bool: if worked or not
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
# Retrieve the current appointment
|
|
||||||
item = termin.get_item(id)
|
item = termin.get_item(id)
|
||||||
if not item:
|
if not item:
|
||||||
return False
|
return False
|
||||||
@@ -276,10 +273,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
|
||||||
|
|
||||||
# Append the new booking as a tuple (start_time, name)
|
slots.append((date_start_time, name, custom))
|
||||||
slots.append((date_start_time, name))
|
|
||||||
|
|
||||||
# Update the appointment in the database
|
|
||||||
success = termin.update(id, slots)
|
success = termin.update(id, slots)
|
||||||
return bool(success)
|
return bool(success)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -288,19 +282,12 @@ def book_slot(id, date_start_time, name):
|
|||||||
|
|
||||||
|
|
||||||
def remove_slot(id, date_start_time, name):
|
def remove_slot(id, date_start_time, name):
|
||||||
"""
|
|
||||||
Remove a booked slot for an appointment.
|
|
||||||
|
|
||||||
Returns True if the removal succeeded, False otherwise.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
# Prefer DB-level remove if available
|
|
||||||
if hasattr(termin, 'remove_slot'):
|
if hasattr(termin, 'remove_slot'):
|
||||||
removed = termin.remove_slot(id, date_start_time, name)
|
removed = termin.remove_slot(id, date_start_time, name)
|
||||||
if removed:
|
if removed:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Fallback: fetch, filter, and replace the slot list
|
|
||||||
item = termin.get_item(id)
|
item = termin.get_item(id)
|
||||||
if not item:
|
if not item:
|
||||||
return False
|
return False
|
||||||
@@ -309,7 +296,6 @@ def remove_slot(id, date_start_time, name):
|
|||||||
new_slots = []
|
new_slots = []
|
||||||
for s in slots:
|
for s in slots:
|
||||||
try:
|
try:
|
||||||
# s may be list or tuple like [start_time, name]
|
|
||||||
if isinstance(s, (list, tuple)) and len(s) >= 2 and s[0] == date_start_time and s[1] == name:
|
if isinstance(s, (list, tuple)) and len(s) >= 2 and s[0] == date_start_time and s[1] == name:
|
||||||
continue
|
continue
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -324,9 +310,6 @@ def remove_slot(id, date_start_time, name):
|
|||||||
|
|
||||||
|
|
||||||
def remove_appointment(id):
|
def remove_appointment(id):
|
||||||
"""
|
|
||||||
Remove an entire appointment by id.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
return bool(termin.remove(id))
|
return bool(termin.remove(id))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -334,17 +317,6 @@ def remove_appointment(id):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def get_available(id):
|
def get_available(id):
|
||||||
"""
|
|
||||||
Gets the available time slots -> more over it returns the time frame and the allready booked slots also the lenght.
|
|
||||||
And checks if there are slots left.
|
|
||||||
|
|
||||||
Input:
|
|
||||||
- id: id of the appointment
|
|
||||||
|
|
||||||
Output:
|
|
||||||
- dict: all the needet information -> [Start_date, End_date, (first day Time Frame,
|
|
||||||
second day Time frame, third etc.), slot lenght, (bookedslots -> list)]
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
termin_range = termin.get_item(id)
|
termin_range = termin.get_item(id)
|
||||||
if not termin_range:
|
if not termin_range:
|
||||||
@@ -353,22 +325,19 @@ def get_available(id):
|
|||||||
date_start = termin_range.get('date_start')
|
date_start = termin_range.get('date_start')
|
||||||
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', [])
|
||||||
slot_lenght = termin_range.get('slot_lenght')
|
|
||||||
total_slots = termin_range.get('slots', 0)
|
|
||||||
# Ensure numeric fields are cast to int when stored as strings
|
|
||||||
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 Exception:
|
||||||
total_slots = 0
|
total_slots = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
slot_lenght = int(termin_range.get('slot_lenght') or 0)
|
slot_length = int(termin_range.get('slot_length') or termin_range.get('slot_lenght') or 0)
|
||||||
except Exception:
|
except Exception:
|
||||||
slot_lenght = termin_range.get('slot_lenght')
|
slot_length = termin_range.get('slot_length') or termin_range.get('slot_lenght')
|
||||||
|
|
||||||
booked = termin_range.get('slots_booked', []) or []
|
booked = termin_range.get('slots_booked', []) or []
|
||||||
|
|
||||||
# Normalize booked entries to dicts for easier consumption
|
|
||||||
normalized = []
|
normalized = []
|
||||||
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:
|
||||||
@@ -388,7 +357,8 @@ def get_available(id):
|
|||||||
'date_start': date_start,
|
'date_start': date_start,
|
||||||
'date_end': date_end,
|
'date_end': date_end,
|
||||||
'time_span': time_span,
|
'time_span': time_span,
|
||||||
'slot_lenght': slot_lenght,
|
'slot_length': slot_length,
|
||||||
|
'slot_lenght': slot_length,
|
||||||
'slots_total': total_slots,
|
'slots_total': total_slots,
|
||||||
'slots_booked': normalized,
|
'slots_booked': normalized,
|
||||||
'slots_left': slots_left,
|
'slots_left': slots_left,
|
||||||
@@ -398,22 +368,10 @@ def get_available(id):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
def get_available_user(id):
|
def get_available_user(id):
|
||||||
"""
|
|
||||||
Gets the available time slots -> more over it returns the time frame and the allready booked slots also the lenght.
|
|
||||||
And checks if there are slots left.
|
|
||||||
|
|
||||||
Input:
|
|
||||||
- id: id of the appointment
|
|
||||||
|
|
||||||
Output:
|
|
||||||
- dict: all the needet information -> [Start_date, End_date, (first day Time Frame,
|
|
||||||
second day Time frame, third etc.), slot lenght, (bookedslots -> list)]
|
|
||||||
"""
|
|
||||||
return get_available(id)
|
return get_available(id)
|
||||||
|
|
||||||
|
|
||||||
def get_user_upcoming_events(user: str, limit: int = 25) -> list[dict]:
|
def get_user_upcoming_events(user: str, limit: int = 25) -> list[dict]:
|
||||||
"""Return upcoming appointment plans for overview display."""
|
|
||||||
user_name = str(user or '').strip()
|
user_name = str(user or '').strip()
|
||||||
if not user_name:
|
if not user_name:
|
||||||
return []
|
return []
|
||||||
@@ -459,6 +417,8 @@ def get_user_upcoming_events(user: str, limit: int = 25) -> list[dict]:
|
|||||||
'note': str(item.get('note') or ''),
|
'note': str(item.get('note') or ''),
|
||||||
'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 ''),
|
||||||
|
'custom_fields': list(item.get('custom_fields')),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,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:
|
||||||
@@ -72,6 +76,9 @@ def client(appointment_id):
|
|||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
start_daytime = request.form.get('start_day_time')
|
start_daytime = request.form.get('start_day_time')
|
||||||
username = request.form.get('client_name')
|
username = request.form.get('client_name')
|
||||||
|
|
||||||
|
custom_answers = request.form.getlist('custom_answers')
|
||||||
|
|
||||||
if not start_daytime or not username:
|
if not start_daytime or not username:
|
||||||
flash('Bitte Name und gewünschte Uhrzeit angeben.', 'error')
|
flash('Bitte Name und gewünschte Uhrzeit angeben.', 'error')
|
||||||
return render_template(
|
return render_template(
|
||||||
@@ -81,9 +88,10 @@ 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
|
||||||
)
|
)
|
||||||
|
|
||||||
if appointment_service.book_slot(appointment_id, start_daytime, username):
|
if appointment_service.book_slot(appointment_id, start_daytime, username, custom=custom_answers):
|
||||||
return redirect(
|
return redirect(
|
||||||
url_for(
|
url_for(
|
||||||
'terminplaner.client_success',
|
'terminplaner.client_success',
|
||||||
@@ -103,6 +111,7 @@ 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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -169,12 +178,14 @@ 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_lenght = request.form.get('slot_lenght')
|
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()
|
||||||
|
custom = request.form.getlist('custom_fields')
|
||||||
|
|
||||||
if not start or not end or not time or not slots_amount or not slot_lenght:
|
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(
|
||||||
'termin_configure.html',
|
'termin_configure.html',
|
||||||
@@ -183,7 +194,8 @@ def configure():
|
|||||||
email_service_enabled=cfg.EMAIL_ENABLED,
|
email_service_enabled=cfg.EMAIL_ENABLED,
|
||||||
)
|
)
|
||||||
|
|
||||||
result = appointment_service.new(start, end, time, slots_amount, slot_lenght, session["username"], mail, note, calendar_enabled=add_to_calendar)
|
# 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, 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',
|
||||||
@@ -192,6 +204,7 @@ def configure():
|
|||||||
calendar_link=result.get('calendar_link'),
|
calendar_link=result.get('calendar_link'),
|
||||||
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,
|
||||||
)
|
)
|
||||||
elif request.method == "GET":
|
elif request.method == "GET":
|
||||||
return render_template(
|
return render_template(
|
||||||
@@ -201,6 +214,7 @@ def configure():
|
|||||||
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -214,8 +228,10 @@ def calendar_export(appointment_id):
|
|||||||
if not ics_content:
|
if not ics_content:
|
||||||
return _appointment_not_found_response()
|
return _appointment_not_found_response()
|
||||||
|
|
||||||
|
title = str(request.args.get('title', '') or '').strip() or appointment_id
|
||||||
|
|
||||||
response = Response(ics_content, mimetype='text/calendar; charset=utf-8')
|
response = Response(ics_content, mimetype='text/calendar; charset=utf-8')
|
||||||
response.headers['Content-Disposition'] = f'attachment; filename=terminplan-{appointment_id}.ics'
|
response.headers['Content-Disposition'] = f'attachment; filename=terminplan-{title}-{appointment_id}.ics'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -231,8 +247,10 @@ def client_slot_calendar_export(appointment_id):
|
|||||||
if not ics_content:
|
if not ics_content:
|
||||||
return _appointment_not_found_response()
|
return _appointment_not_found_response()
|
||||||
|
|
||||||
|
title = str(request.args.get('title', '') or '').strip() or appointment_id
|
||||||
|
|
||||||
response = Response(ics_content, mimetype='text/calendar; charset=utf-8')
|
response = Response(ics_content, mimetype='text/calendar; charset=utf-8')
|
||||||
response.headers['Content-Disposition'] = f'attachment; filename=termin-{appointment_id}-{slot_start.replace(" ", "_").replace(":", "")}.ics'
|
response.headers['Content-Disposition'] = f'attachment; filename=termin-{title}-{appointment_id}-{slot_start.replace(" ", "_").replace(":", "")}.ics'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@appoint_bp.route('/')
|
@appoint_bp.route('/')
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ openpyxl
|
|||||||
cryptography>=42.0.0
|
cryptography>=42.0.0
|
||||||
pywebpush
|
pywebpush
|
||||||
py-vapid>=1.9.0
|
py-vapid>=1.9.0
|
||||||
|
beautifulsoup4
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -28,11 +28,10 @@
|
|||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link" id="tab-legal" data-bs-toggle="tab" data-bs-target="#pane-legal"
|
<button class="nav-link" id="tab-legal" data-bs-toggle="tab" data-bs-target="#pane-legal"
|
||||||
type="button" role="tab">Rechtsgrundlage</button>
|
type="button" role="tab">Rechtsgrundlage</button>
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<!-- ── NOTICE ────────────────────────────────────────────────────── -->
|
<!-- ── NOTICE ────────────────────────────────────────────────────── -->
|
||||||
<div class="tab-pane fade" id="pane-notice" role="tabpanel">
|
<div class="tab-pane" id="pane-notice" role="tabpanel">
|
||||||
<div class="license-content">
|
<div class="license-content">
|
||||||
<h2>NOTICE – Urheberrechtliche Hinweise</h2>
|
<h2>NOTICE – Urheberrechtliche Hinweise</h2>
|
||||||
<p><strong>Invario Modul Suite</strong><br>Copyright © 2026 Invario UG</p>
|
<p><strong>Invario Modul Suite</strong><br>Copyright © 2026 Invario UG</p>
|
||||||
@@ -59,14 +58,14 @@
|
|||||||
|
|
||||||
<h4>Frontend / Laufzeit-Assets</h4>
|
<h4>Frontend / Laufzeit-Assets</h4>
|
||||||
<ul>
|
<ul>
|
||||||
<li>html5-qrcode (MIT) © 2020-2025 Manoj Brahmbhatt (mebjas)</li>
|
<li>quagga2 (MIT) © 2014 Christoph Oberhofer, © 2019 Eric Blade and contributors</li>
|
||||||
</ul>
|
</ul>
|
||||||
<hr>
|
<hr>
|
||||||
</div>
|
</div>
|
||||||
</div><!-- /#pane-notice -->
|
</div><!-- /#pane-notice -->
|
||||||
|
|
||||||
<!-- ── DATENSCHUTZ ───────────────────────────────────────────────── -->
|
<!-- ── DATENSCHUTZ ───────────────────────────────────────────────── -->
|
||||||
<div class="tab-pane fade" id="pane-privacy" role="tabpanel">
|
<div class="tab-pane" id="pane-privacy" role="tabpanel">
|
||||||
<div class="license-content">
|
<div class="license-content">
|
||||||
<h2>Datenschutzerklärung (Privacy Policy)</h2>
|
<h2>Datenschutzerklärung (Privacy Policy)</h2>
|
||||||
|
|
||||||
@@ -108,7 +107,7 @@
|
|||||||
</div><!-- /#pane-privacy -->
|
</div><!-- /#pane-privacy -->
|
||||||
|
|
||||||
<!-- ── SICHERHEIT ────────────────────────────────────────────────── -->
|
<!-- ── SICHERHEIT ────────────────────────────────────────────────── -->
|
||||||
<div class="tab-pane fade" id="pane-security" role="tabpanel">
|
<div class="tab-pane" id="pane-security" role="tabpanel">
|
||||||
<div class="license-content">
|
<div class="license-content">
|
||||||
<h2>Sicherheitsrichtlinie (Security Policy)</h2>
|
<h2>Sicherheitsrichtlinie (Security Policy)</h2>
|
||||||
|
|
||||||
@@ -129,7 +128,7 @@
|
|||||||
</div><!-- /#pane-security -->
|
</div><!-- /#pane-security -->
|
||||||
|
|
||||||
<!-- ── DATENVERARBEITUNG ─────────────────────────────────────────── -->
|
<!-- ── DATENVERARBEITUNG ─────────────────────────────────────────── -->
|
||||||
<div class="tab-pane fade" id="pane-data" role="tabpanel">
|
<div class="tab-pane" id="pane-data" role="tabpanel">
|
||||||
<div class="license-content">
|
<div class="license-content">
|
||||||
<h2>Dokumentation der Datenverarbeitung (VVT)</h2>
|
<h2>Dokumentation der Datenverarbeitung (VVT)</h2>
|
||||||
|
|
||||||
@@ -157,7 +156,7 @@
|
|||||||
</div><!-- /#pane-data -->
|
</div><!-- /#pane-data -->
|
||||||
|
|
||||||
<!-- ── RECHTSGRUNDLAGE ───────────────────────────────────────────── -->
|
<!-- ── RECHTSGRUNDLAGE ───────────────────────────────────────────── -->
|
||||||
<div class="tab-pane fade" id="pane-legal" role="tabpanel">
|
<div class="tab-pane" id="pane-legal" role="tabpanel">
|
||||||
<div class="license-content">
|
<div class="license-content">
|
||||||
<h2>Rechtsgrundlage der Nutzung</h2>
|
<h2>Rechtsgrundlage der Nutzung</h2>
|
||||||
<p>Um das Inventarsystem DSGVO-konform zu betreiben, muss der Betreiber eine der folgenden Grundlagen festlegen:</p>
|
<p>Um das Inventarsystem DSGVO-konform zu betreiben, muss der Betreiber eine der folgenden Grundlagen festlegen:</p>
|
||||||
@@ -170,9 +169,6 @@
|
|||||||
Einwilligung vorliegen (Art. 6 Abs. 1 lit. a DSGVO).</li>
|
Einwilligung vorliegen (Art. 6 Abs. 1 lit. a DSGVO).</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
</div><!-- /#pane-legal -->
|
|
||||||
|
|
||||||
</div><!-- /.tab-content -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -373,7 +373,7 @@
|
|||||||
|
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<div class="filter-header">
|
<div class="filter-header">
|
||||||
<label>Thema:</label>
|
<label>Schlagwort:</label>
|
||||||
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter3-dropdown')">▼</button>
|
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter3-dropdown')">▼</button>
|
||||||
<button type="button" class="clear-filter" onclick="clearFilter(3)">Clear</button>
|
<button type="button" class="clear-filter" onclick="clearFilter(3)">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -420,7 +420,7 @@
|
|||||||
<span>Ort</span>
|
<span>Ort</span>
|
||||||
<span>Unterrichtsfach</span>
|
<span>Unterrichtsfach</span>
|
||||||
<span>Jahrgangsstufe</span>
|
<span>Jahrgangsstufe</span>
|
||||||
<span>Thema</span>
|
<span>Schlagwort</span>
|
||||||
<span>Barcode</span>
|
<span>Barcode</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="items-container" class="items-container">
|
<div id="items-container" class="items-container">
|
||||||
@@ -1057,7 +1057,7 @@
|
|||||||
<p class="item-col-location"><strong>Ort:</strong> ${item.Ort || '-'}</p>
|
<p class="item-col-location"><strong>Ort:</strong> ${item.Ort || '-'}</p>
|
||||||
<p class="item-col-filter1"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p>
|
<p class="item-col-filter1"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p>
|
||||||
<p class="item-col-filter2"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p>
|
<p class="item-col-filter2"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p>
|
||||||
<p class="item-col-filter3"><strong>Thema:</strong> ${filter3Display}${filter3More}</p>
|
<p class="item-col-filter3"><strong>Schlagwort:</strong> ${filter3Display}${filter3More}</p>
|
||||||
<p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
|
<p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
|
||||||
<p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p>
|
<p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p>
|
||||||
${isGroupedItem ? `<p class="item-col-count"><strong>Verfügbar:</strong> ${availableGroupedCount}</p>` : ''}
|
${isGroupedItem ? `<p class="item-col-count"><strong>Verfügbar:</strong> ${availableGroupedCount}</p>` : ''}
|
||||||
@@ -1682,7 +1682,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-group">
|
<div class="detail-group">
|
||||||
<div class="detail-label">Thema:</div>
|
<div class="detail-label">Schlagwort:</div>
|
||||||
<div class="detail-value">${filter3Array.join(', ') || '-'}</div>
|
<div class="detail-value">${filter3Array.join(', ') || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2395,7 +2395,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
|
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<div class="filter-header">
|
<div class="filter-header">
|
||||||
<label>Thema:</label>
|
<label>Schlagwort:</label>
|
||||||
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter3-dropdown')">▼</button>
|
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter3-dropdown')">▼</button>
|
||||||
<button type="button" class="clear-filter" onclick="clearFilter(3)">Clear</button>
|
<button type="button" class="clear-filter" onclick="clearFilter(3)">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2446,7 +2446,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
<span>Ort</span>
|
<span>Ort</span>
|
||||||
<span>Unterrichtsfach</span>
|
<span>Unterrichtsfach</span>
|
||||||
<span>Jahrgangsstufe</span>
|
<span>Jahrgangsstufe</span>
|
||||||
<span>Thema</span>
|
<span>Schlagwort</span>
|
||||||
<span>Barcode</span>
|
<span>Barcode</span>
|
||||||
<span>Anzahl</span>
|
<span>Anzahl</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -2567,7 +2567,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Thema:</h3>
|
<h3>Schlagwort:</h3>
|
||||||
<div class="multi-filter">
|
<div class="multi-filter">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="edit-filter3-1">Wert 1:</label>
|
<label for="edit-filter3-1">Wert 1:</label>
|
||||||
@@ -3686,7 +3686,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
<p class="item-col-location"><strong>Ort:</strong> ${item.Ort || '-'}</p>
|
<p class="item-col-location"><strong>Ort:</strong> ${item.Ort || '-'}</p>
|
||||||
<p class="item-col-filter1"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p>
|
<p class="item-col-filter1"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p>
|
||||||
<p class="item-col-filter2"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p>
|
<p class="item-col-filter2"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p>
|
||||||
<p class="item-col-filter3"><strong>Thema:</strong> ${filter3Display}${filter3More}</p>
|
<p class="item-col-filter3"><strong>Schlagwort:</strong> ${filter3Display}${filter3More}</p>
|
||||||
<p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
|
<p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
|
||||||
<p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p>
|
<p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p>
|
||||||
${hasDamage ? `<div class="damage-badge">${damageCount > 0 ? `Schäden gemeldet: ${damageCount}` : 'Schäden gemeldet'}</div>` : ''}
|
${hasDamage ? `<div class="damage-badge">${damageCount > 0 ? `Schäden gemeldet: ${damageCount}` : 'Schäden gemeldet'}</div>` : ''}
|
||||||
@@ -4229,7 +4229,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
document.getElementById('edit-filter2-3').value = filter2Array[2] || '';
|
document.getElementById('edit-filter2-3').value = filter2Array[2] || '';
|
||||||
document.getElementById('edit-filter2-4').value = filter2Array[3] || '';
|
document.getElementById('edit-filter2-4').value = filter2Array[3] || '';
|
||||||
|
|
||||||
// Fill in filter 3 (Thema)
|
// Fill in filter 3 (Schlagwort)
|
||||||
const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 : (item.Filter3 ? [item.Filter3] : []);
|
const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 : (item.Filter3 ? [item.Filter3] : []);
|
||||||
document.getElementById('edit-filter3-1').value = filter3Array[0] || '';
|
document.getElementById('edit-filter3-1').value = filter3Array[0] || '';
|
||||||
document.getElementById('edit-filter3-2').value = filter3Array[1] || '';
|
document.getElementById('edit-filter3-2').value = filter3Array[1] || '';
|
||||||
@@ -4543,7 +4543,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-group">
|
<div class="detail-group">
|
||||||
<div class="detail-label">Thema:</div>
|
<div class="detail-label">Schlagwort:</div>
|
||||||
<div class="detail-value">${filter3Array.join(', ') || '-'}</div>
|
<div class="detail-value">${filter3Array.join(', ') || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
<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</h1>
|
<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>
|
<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>
|
||||||
|
|
||||||
<div class="p-3 rounded-3 bg-light mb-3">
|
<div class="p-3 rounded-3 bg-light mb-3">
|
||||||
@@ -109,6 +109,16 @@
|
|||||||
<label for="client_name" class="form-label fw-semibold">Ihr Name</label>
|
<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>
|
<input type="text" id="client_name" name="client_name" class="form-control form-control-lg" placeholder="Vor- und Nachname" required>
|
||||||
</div>
|
</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">
|
<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>
|
<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>
|
<a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main', tenant=tenant_id) }}">Zur Übersicht</a>
|
||||||
|
|||||||
@@ -14,13 +14,17 @@
|
|||||||
<div class="card-body p-4 p-md-5 bg-white">
|
<div class="card-body p-4 p-md-5 bg-white">
|
||||||
<form method="post" action="{{ url_for('terminplaner.configure') }}" class="vstack gap-3">
|
<form method="post" action="{{ url_for('terminplaner.configure') }}" class="vstack gap-3">
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="title" class="form-label fw-semibold">Titel des Terminplans</label>
|
||||||
|
<input type="text" id="title" name="title" class="form-control form-control-lg" placeholder="z.B. Elternsprechtag Klasse 10a" value="{{ title or '' }}">
|
||||||
|
</div>
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<label for="start_date" class="form-label fw-semibold">Startdatum</label>
|
<label for="start_date" class="form-label fw-semibold">Startdatum</label>
|
||||||
<input type="date" id="start_date" name="start_date" class="form-control form-control-lg" required>
|
<input type="date" id="start_date" name="start_date" class="form-control form-control-lg">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<label for="end_date" class="form-label fw-semibold">Enddatum</label>
|
<label for="end_date" class="form-label fw-semibold">Enddatum</label>
|
||||||
<input type="date" id="end_date" name="end_date" class="form-control form-control-lg" required>
|
<input type="date" id="end_date" name="end_date" class="form-control form-control-lg">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -28,52 +32,73 @@
|
|||||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-2">
|
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-2">
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label fw-semibold mb-0">Zeitfenster pro Tag</label>
|
<label class="form-label fw-semibold mb-0">Zeitfenster pro Tag</label>
|
||||||
<div class="form-text mb-0">Sobald Start- und Enddatum gesetzt sind, wird für jeden Tag automatisch ein eigener Eintrag erzeugt.</div>
|
<div class="form-text mb-0">Die Start-, End- und Pausenzeiten werden auf alle erzeugten Tage angewandt.</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-outline-primary btn-sm" id="build_time_frame">Tage aus Zeitraum erzeugen</button>
|
<button type="button" class="btn btn-outline-primary btn-sm" id="build_time_frame">Tage aus Zeitraum erzeugen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-3 mb-3">
|
<div class="row g-3 mb-3">
|
||||||
<div class="col-12 col-md-6 col-xl-4">
|
<div class="col-12 col-sm-6 col-lg-3">
|
||||||
<label for="default_day_start" class="form-label fw-semibold">Standard-Startzeit</label>
|
<label for="default_day_start" class="form-label fw-semibold">Standard-Start</label>
|
||||||
<input type="time" id="default_day_start" class="form-control" value="08:00">
|
<input type="time" id="default_day_start" class="form-control" value="08:00">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6 col-xl-4">
|
<div class="col-12 col-sm-6 col-lg-3">
|
||||||
<label for="default_day_end" class="form-label fw-semibold">Standard-Endzeit</label>
|
<label for="default_day_end" class="form-label fw-semibold">Standard-Ende</label>
|
||||||
<input type="time" id="default_day_end" class="form-control" value="12:00">
|
<input type="time" id="default_day_end" class="form-control" value="16:00">
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-sm-6 col-lg-3">
|
||||||
|
<label for="default_pause_start" class="form-label fw-semibold text-danger">Pause von</label>
|
||||||
|
<input type="time" id="default_pause_start" class="form-control border-danger-subtle" value="12:00">
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-sm-6 col-lg-3">
|
||||||
|
<label for="default_pause_end" class="form-label fw-semibold text-danger">Pause bis</label>
|
||||||
|
<input type="time" id="default_pause_end" class="form-control border-danger-subtle" value="12:45">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="time_frame_days" class="vstack gap-2"></div>
|
<div id="time_frame_days" class="vstack gap-2"></div>
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<label for="time_frame" class="form-label fw-semibold">Gespeichertes Zeitfenster</label>
|
<label for="time_frame" class="form-label fw-semibold">Gespeichertes Zeitfenster (Übertragung ans Backend)</label>
|
||||||
<textarea id="time_frame" name="time_frame" class="form-control font-monospace" rows="5" placeholder="Wird automatisch aus den Tagen erzeugt" required></textarea>
|
<textarea id="time_frame" name="time_frame" class="form-control font-monospace" rows="5" placeholder="Wird automatisch aus den Tagen erzeugt" readonly></textarea>
|
||||||
<div class="form-text">Das Formular überträgt die erzeugten Tageszeilen an das Backend. Sie können die Liste hier bei Bedarf noch anpassen.</div>
|
<div class="form-text">Pausen teilen den Tag automatisch in zwei buchbare Blöcke, sodass die Pause sicher blockiert ist.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<label for="slots_amounts" class="form-label fw-semibold">Anzahl Slots</label>
|
<label for="slot_length" class="form-label fw-semibold">Slot-Länge in Minuten</label>
|
||||||
<input type="number" id="slots_amounts" name="slots_amounts" class="form-control" min="1" value="1" required>
|
<input type="number" id="slot_length" name="slot_length" class="form-control form-control-lg" min="1" value="45">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<label for="slot_lenght" class="form-label fw-semibold">Slot-Länge in Minuten</label>
|
<label class="form-label fw-semibold text-primary">Automatisch berechnete Slots</label>
|
||||||
<input type="number" id="slot_lenght" name="slot_lenght" class="form-control" min="1" value="45" required>
|
<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>
|
||||||
|
<input type="hidden" id="slots_amounts" name="slots_amounts" value="0">
|
||||||
|
<div class="form-text">Wird aus der gebuchten Zeit (abzüglich Pausen) und der Slot-Länge berechnet.</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{% if mail_service_enabled %}
|
||||||
<label for="mail" class="form-label fw-semibold">Empfänger-Mails</label>
|
<div>
|
||||||
<input type="text" id="mail" name="mail" class="form-control" placeholder="adresse1@schule.de, adresse2@schule.de">
|
<label for="mail" class="form-label fw-semibold">Empfänger-Mails</label>
|
||||||
</div>
|
<input type="text" id="mail" name="mail" class="form-control" placeholder="adresse1@schule.de, adresse2@schule.de">
|
||||||
|
</div>
|
||||||
<div>
|
|
||||||
<label for="note" class="form-label fw-semibold">Notiz</label>
|
|
||||||
<textarea id="note" name="note" class="form-control" rows="4" placeholder="Optionaler Einführungstext für die Mail"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="note" class="form-label fw-semibold">Notiz</label>
|
||||||
|
<textarea id="note" name="note" class="form-control" rows="4" placeholder="Optionaler Einführungstext für die Mail"></textarea>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="checkbox" id="add_to_calendar" name="add_to_calendar" {% if add_to_calendar %}checked{% endif %}>
|
<input class="form-check-input" type="checkbox" id="add_to_calendar" name="add_to_calendar" {% if add_to_calendar %}checked{% endif %}>
|
||||||
<label class="form-check-label fw-semibold" for="add_to_calendar">Kalendereintrag für den Nutzer erzeugen</label>
|
<label class="form-check-label fw-semibold" for="add_to_calendar">Kalendereintrag für den Nutzer erzeugen</label>
|
||||||
@@ -92,7 +117,7 @@
|
|||||||
<div class="alert alert-success mt-4 shadow-sm rounded-4">
|
<div class="alert alert-success mt-4 shadow-sm rounded-4">
|
||||||
<div class="fw-bold mb-1">Buchungslink erstellt</div>
|
<div class="fw-bold mb-1">Buchungslink erstellt</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
{% if email_service_enabled %}
|
{% if mail_service_enabled %}
|
||||||
Teilen Sie diesen Link mit den Teilnehmenden oder versenden Sie ihn direkt per E-Mail.
|
Teilen Sie diesen Link mit den Teilnehmenden oder versenden Sie ihn direkt per E-Mail.
|
||||||
{% else %}
|
{% else %}
|
||||||
Der E-Mail-Service ist deaktiviert. Teilen Sie diesen Link manuell mit den Teilnehmenden.
|
Der E-Mail-Service ist deaktiviert. Teilen Sie diesen Link manuell mit den Teilnehmenden.
|
||||||
@@ -117,30 +142,31 @@
|
|||||||
const buildButton = document.getElementById('build_time_frame');
|
const buildButton = document.getElementById('build_time_frame');
|
||||||
const daysContainer = document.getElementById('time_frame_days');
|
const daysContainer = document.getElementById('time_frame_days');
|
||||||
const timeFrameTextarea = document.getElementById('time_frame');
|
const timeFrameTextarea = document.getElementById('time_frame');
|
||||||
|
|
||||||
const defaultStartInput = document.getElementById('default_day_start');
|
const defaultStartInput = document.getElementById('default_day_start');
|
||||||
const defaultEndInput = document.getElementById('default_day_end');
|
const defaultEndInput = document.getElementById('default_day_end');
|
||||||
|
const defaultPauseStartInput = document.getElementById('default_pause_start');
|
||||||
|
const defaultPauseEndInput = document.getElementById('default_pause_end');
|
||||||
|
|
||||||
|
const slotLengthInput = document.getElementById('slot_length');
|
||||||
|
const slotsAmountsInput = document.getElementById('slots_amounts');
|
||||||
|
const slotsAmountsDisplay = document.getElementById('slots_amounts_display'); // Neu: Anzeige-Element
|
||||||
|
|
||||||
if (!startDateInput || !endDateInput || !buildButton || !daysContainer || !timeFrameTextarea || !defaultStartInput || !defaultEndInput) {
|
if (!startDateInput || !endDateInput || !buildButton || !daysContainer || !timeFrameTextarea) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const weekdayFormatter = new Intl.DateTimeFormat('de-DE', {
|
const weekdayFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||||
weekday: 'long',
|
weekday: 'short',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
});
|
});
|
||||||
|
|
||||||
function parseDate(value) {
|
function parseDate(value) {
|
||||||
if (!value) {
|
if (!value) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = value.split('-').map(Number);
|
const parts = value.split('-').map(Number);
|
||||||
if (parts.length !== 3 || parts.some(Number.isNaN)) {
|
if (parts.length !== 3 || parts.some(Number.isNaN)) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Date(parts[0], parts[1] - 1, parts[2]);
|
return new Date(parts[0], parts[1] - 1, parts[2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,16 +187,92 @@
|
|||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function timeToMinutes(timeStr) {
|
||||||
|
if (!timeStr) return 0;
|
||||||
|
const parts = timeStr.split(':').map(Number);
|
||||||
|
return parts[0] * 60 + parts[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSlotsDisplay(totalSlots) {
|
||||||
|
if (slotsAmountsInput) slotsAmountsInput.value = totalSlots;
|
||||||
|
if (slotsAmountsDisplay) slotsAmountsDisplay.innerText = totalSlots + " Slots gesamt";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Berechnet die Slots basierend auf den konfigurierten Zeiten & Pausen
|
||||||
|
function calculateSlots() {
|
||||||
|
if (!slotLengthInput || !slotsAmountsInput) return;
|
||||||
|
|
||||||
|
const slotLength = parseInt(slotLengthInput.value, 10);
|
||||||
|
if (isNaN(slotLength) || slotLength <= 0) {
|
||||||
|
updateSlotsDisplay(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalSlots = 0;
|
||||||
|
const rows = Array.from(daysContainer.querySelectorAll('[data-day-row]'));
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const startTime = row.querySelector('[data-time-start]')?.value;
|
||||||
|
const endTime = row.querySelector('[data-time-end]')?.value;
|
||||||
|
const pauseStart = row.querySelector('[data-time-pause-start]')?.value;
|
||||||
|
const pauseEnd = row.querySelector('[data-time-pause-end]')?.value;
|
||||||
|
|
||||||
|
if (!startTime || !endTime) return;
|
||||||
|
|
||||||
|
const startMins = timeToMinutes(startTime);
|
||||||
|
const endMins = timeToMinutes(endTime);
|
||||||
|
const pauseStartMins = timeToMinutes(pauseStart);
|
||||||
|
const pauseEndMins = timeToMinutes(pauseEnd);
|
||||||
|
|
||||||
|
if (endMins <= startMins) return; // Ungültige Zeit
|
||||||
|
|
||||||
|
// Wenn eine gültige Pause innerhalb der Start/Endzeit existiert
|
||||||
|
if (pauseStartMins > 0 && pauseEndMins > 0 && pauseStartMins < pauseEndMins && pauseStartMins > startMins && pauseStartMins < endMins) {
|
||||||
|
// Segment 1 (Vor der Pause)
|
||||||
|
const segment1 = pauseStartMins - startMins;
|
||||||
|
totalSlots += Math.floor(segment1 / slotLength);
|
||||||
|
|
||||||
|
// Segment 2 (Nach der Pause)
|
||||||
|
const effectivePauseEnd = Math.min(pauseEndMins, endMins);
|
||||||
|
const segment2 = endMins - effectivePauseEnd;
|
||||||
|
if (segment2 > 0) {
|
||||||
|
totalSlots += Math.floor(segment2 / slotLength);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Ohne Pause oder ungültige Pause -> Komplett durchrechnen
|
||||||
|
totalSlots += Math.floor((endMins - startMins) / slotLength);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateSlotsDisplay(totalSlots);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synchronisiert das Textfeld und fügt den Break-Cut hinzu
|
||||||
function syncTextarea() {
|
function syncTextarea() {
|
||||||
const rows = Array.from(daysContainer.querySelectorAll('[data-day-row]'));
|
const rows = Array.from(daysContainer.querySelectorAll('[data-day-row]'));
|
||||||
const lines = rows.map(function (row) {
|
const lines = [];
|
||||||
|
|
||||||
|
rows.forEach(function (row) {
|
||||||
const dayValue = row.getAttribute('data-day-value') || '';
|
const dayValue = row.getAttribute('data-day-value') || '';
|
||||||
const startTime = row.querySelector('[data-time-start]')?.value || '';
|
const startTime = row.querySelector('[data-time-start]')?.value || '';
|
||||||
const endTime = row.querySelector('[data-time-end]')?.value || '';
|
const endTime = row.querySelector('[data-time-end]')?.value || '';
|
||||||
return `${dayValue} ${startTime}-${endTime}`.trim();
|
const pauseStart = row.querySelector('[data-time-pause-start]')?.value || '';
|
||||||
}).filter(Boolean);
|
const pauseEnd = row.querySelector('[data-time-pause-end]')?.value || '';
|
||||||
|
|
||||||
|
if (!dayValue || !startTime || !endTime) return;
|
||||||
|
|
||||||
|
// Hat der Nutzer eine Pause eingetragen, die innerhalb des Tagesfensters liegt?
|
||||||
|
if (pauseStart && pauseEnd && pauseStart < pauseEnd && pauseStart > startTime && pauseStart < endTime) {
|
||||||
|
// Zeile am Pausenbeginn aufsplitten -> 2 Blöcke = automatische Pause
|
||||||
|
lines.push(`${dayValue} ${startTime}-${pauseStart}`);
|
||||||
|
lines.push(`${dayValue} ${pauseEnd}-${endTime}`);
|
||||||
|
} else {
|
||||||
|
lines.push(`${dayValue} ${startTime}-${endTime}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
timeFrameTextarea.value = lines.join('\n');
|
timeFrameTextarea.value = lines.join('\n');
|
||||||
|
calculateSlots(); // Nach jedem Update neu berechnen
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRows() {
|
function renderRows() {
|
||||||
@@ -193,40 +295,57 @@
|
|||||||
|
|
||||||
endDateInput.setCustomValidity('');
|
endDateInput.setCustomValidity('');
|
||||||
|
|
||||||
|
// Bisherige Werte sichern
|
||||||
const existingValues = new Map();
|
const existingValues = new Map();
|
||||||
Array.from(daysContainer.querySelectorAll('[data-day-row]')).forEach(function (row) {
|
Array.from(daysContainer.querySelectorAll('[data-day-row]')).forEach(function (row) {
|
||||||
const dayValue = row.getAttribute('data-day-value');
|
const dayValue = row.getAttribute('data-day-value');
|
||||||
const startTime = row.querySelector('[data-time-start]')?.value || '';
|
const startTime = row.querySelector('[data-time-start]')?.value || '';
|
||||||
const endTime = row.querySelector('[data-time-end]')?.value || '';
|
const endTime = row.querySelector('[data-time-end]')?.value || '';
|
||||||
|
const pauseStart = row.querySelector('[data-time-pause-start]')?.value || '';
|
||||||
|
const pauseEnd = row.querySelector('[data-time-pause-end]')?.value || '';
|
||||||
|
|
||||||
if (dayValue) {
|
if (dayValue) {
|
||||||
existingValues.set(dayValue, { startTime, endTime });
|
existingValues.set(dayValue, { startTime, endTime, pauseStart, pauseEnd });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultStart = defaultStartInput.value || '08:00';
|
const defaultStart = defaultStartInput.value || '08:00';
|
||||||
const defaultEnd = defaultEndInput.value || '12:00';
|
const defaultEnd = defaultEndInput.value || '16:00';
|
||||||
|
const defaultPauseStart = defaultPauseStartInput.value || '';
|
||||||
|
const defaultPauseEnd = defaultPauseEndInput.value || '';
|
||||||
const rows = [];
|
const rows = [];
|
||||||
|
|
||||||
for (let current = startDate; current <= endDate; current = addDays(current, 1)) {
|
for (let current = startDate; current <= endDate; current = addDays(current, 1)) {
|
||||||
const dayValue = formatDateForValue(current);
|
const dayValue = formatDateForValue(current);
|
||||||
const preserved = existingValues.get(dayValue) || {};
|
const preserved = existingValues.get(dayValue) || {};
|
||||||
const rowStart = preserved.startTime || defaultStart;
|
|
||||||
const rowEnd = preserved.endTime || defaultEnd;
|
const rowStart = preserved.startTime !== undefined ? preserved.startTime : defaultStart;
|
||||||
|
const rowEnd = preserved.endTime !== undefined ? preserved.endTime : defaultEnd;
|
||||||
|
const rowPauseStart = preserved.pauseStart !== undefined ? preserved.pauseStart : defaultPauseStart;
|
||||||
|
const rowPauseEnd = preserved.pauseEnd !== undefined ? preserved.pauseEnd : defaultPauseEnd;
|
||||||
|
|
||||||
rows.push(`
|
rows.push(`
|
||||||
<div class="border rounded-3 bg-white p-3" data-day-row data-day-value="${dayValue}">
|
<div class="border rounded-3 bg-white p-3" data-day-row data-day-value="${dayValue}">
|
||||||
<div class="row g-2 align-items-end">
|
<div class="row g-2 align-items-end">
|
||||||
<div class="col-12 col-lg-5">
|
<div class="col-12 col-lg-4">
|
||||||
<label class="form-label fw-semibold mb-1">${formatDateForRow(current)}</label>
|
<label class="form-label fw-semibold mb-1">${formatDateForRow(current)}</label>
|
||||||
<div class="text-muted small">${dayValue}</div>
|
<div class="text-muted small">${dayValue}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 col-lg-3">
|
<div class="col-6 col-lg-2">
|
||||||
<label class="form-label mb-1">Von</label>
|
<label class="form-label mb-1 text-muted small">Von</label>
|
||||||
<input type="time" class="form-control" value="${rowStart}" data-time-start>
|
<input type="time" class="form-control form-control-sm" value="${rowStart}" data-time-start>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 col-lg-3">
|
<div class="col-6 col-lg-2">
|
||||||
<label class="form-label mb-1">Bis</label>
|
<label class="form-label mb-1 text-muted small">Bis</label>
|
||||||
<input type="time" class="form-control" value="${rowEnd}" data-time-end>
|
<input type="time" class="form-control form-control-sm" value="${rowEnd}" data-time-end>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-2">
|
||||||
|
<label class="form-label mb-1 text-danger small">Pause ab</label>
|
||||||
|
<input type="time" class="form-control form-control-sm border-danger-subtle" value="${rowPauseStart}" data-time-pause-start>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-2">
|
||||||
|
<label class="form-label mb-1 text-danger small">Pause bis</label>
|
||||||
|
<input type="time" class="form-control form-control-sm border-danger-subtle" value="${rowPauseEnd}" data-time-pause-end>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -242,17 +361,58 @@
|
|||||||
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
|
||||||
buildButton.addEventListener('click', renderRows);
|
buildButton.addEventListener('click', renderRows);
|
||||||
startDateInput.addEventListener('input', renderRows);
|
|
||||||
startDateInput.addEventListener('change', renderRows);
|
startDateInput.addEventListener('change', renderRows);
|
||||||
endDateInput.addEventListener('input', renderRows);
|
|
||||||
endDateInput.addEventListener('change', renderRows);
|
endDateInput.addEventListener('change', renderRows);
|
||||||
defaultStartInput.addEventListener('change', syncTextarea);
|
|
||||||
defaultEndInput.addEventListener('change', syncTextarea);
|
defaultStartInput.addEventListener('change', renderRows);
|
||||||
|
defaultEndInput.addEventListener('change', renderRows);
|
||||||
|
defaultPauseStartInput.addEventListener('change', renderRows);
|
||||||
|
defaultPauseEndInput.addEventListener('change', renderRows);
|
||||||
|
|
||||||
|
// Slot Berechnung auf Ändeurng der Länge antriggern
|
||||||
|
if(slotLengthInput) {
|
||||||
|
slotLengthInput.addEventListener('input', calculateSlots);
|
||||||
|
slotLengthInput.addEventListener('change', calculateSlots);
|
||||||
|
}
|
||||||
|
|
||||||
if (startDateInput.value && endDateInput.value) {
|
if (startDateInput.value && endDateInput.value) {
|
||||||
renderRows();
|
renderRows();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -26,12 +26,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<button id="new-booking" class="primary-button">Zur Termin-Konfiguration</button>
|
<button id="new-booking" class="primary-button">Zur Termin-Konfiguration</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="calendar-legend">
|
|
||||||
<span class="legend-item"><span class="legend-color current"></span> Aktuelle Termine</span>
|
|
||||||
<span class="legend-item"><span class="legend-color planned"></span> Geplante Termine</span>
|
|
||||||
<span class="legend-item"><span class="legend-color completed"></span> Abgeschlossene Termine</span>
|
|
||||||
<span class="legend-item"><span class="legend-color your-bookings"></span> Ihre Termine</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="calendar-options">
|
<div class="calendar-options">
|
||||||
|
|||||||
@@ -71,6 +71,7 @@
|
|||||||
<div class="border rounded-3 p-3">
|
<div class="border rounded-3 p-3">
|
||||||
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3">
|
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
|
<h3 class="h6 fw-bold mb-1">{{ event.title or "Terminplan" }}</h3>
|
||||||
<div class="fw-semibold">{{ event.date_start }} bis {{ event.date_end }}</div>
|
<div class="fw-semibold">{{ event.date_start }} bis {{ event.date_end }}</div>
|
||||||
<div class="text-muted small">ID: {{ event.appointment_id }}</div>
|
<div class="text-muted small">ID: {{ event.appointment_id }}</div>
|
||||||
{% if event.time_span %}
|
{% if event.time_span %}
|
||||||
|
|||||||
@@ -155,6 +155,27 @@
|
|||||||
background-color: #1e7e34;
|
background-color: #1e7e34;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Quagga Scanner Video Styles */
|
||||||
|
#code4-scanner video, #code4-scanner canvas,
|
||||||
|
#isbn-scanner video, #isbn-scanner canvas {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 640px;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#code4-scanner canvas, #isbn-scanner canvas {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#code4-scanner, #isbn-scanner {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
/* ISBN input group styling */
|
/* ISBN input group styling */
|
||||||
.isbn-input-group {
|
.isbn-input-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -767,7 +788,19 @@
|
|||||||
{% if show_library_features %}
|
{% if show_library_features %}
|
||||||
<!-- Library Mode: Single Customizable Filter -->
|
<!-- Library Mode: Single Customizable Filter -->
|
||||||
<div class="filter-inputs">
|
<div class="filter-inputs">
|
||||||
<h3>Kategorie/Typ (customisierbar):</h3>
|
<h3>Medientyp</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<select name="item_type_input" id="item_type_input">
|
||||||
|
<option value="" disabled selected>Medientyp auswählen...</option>
|
||||||
|
<option value="Buch">Buch</option>
|
||||||
|
<option value="Schulbuch">Schulbuch</option>
|
||||||
|
<option value="CD">CD</option>
|
||||||
|
<option value="DVD">DVD</option>
|
||||||
|
<option value="Sonstiges">Sonstiges</option>
|
||||||
|
</select>
|
||||||
|
<small style="display:block; color:#666;">Wählen Sie einen Medientyp aus zur Klassifizierung.</small>
|
||||||
|
</div>
|
||||||
|
<h3>Kategorie/Typ:</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<input type="text" name="library_category" id="library_category" placeholder="z.B. Belletristik, Sachbücher, Nachschlagewerke, etc.">
|
<input type="text" name="library_category" id="library_category" placeholder="z.B. Belletristik, Sachbücher, Nachschlagewerke, etc.">
|
||||||
<small style="display:block; color:#666;">Geben Sie hier eine beliebige Kategorie ein zur freien Klassifizierung.</small>
|
<small style="display:block; color:#666;">Geben Sie hier eine beliebige Kategorie ein zur freien Klassifizierung.</small>
|
||||||
@@ -840,7 +873,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Thema:</h3>
|
<h3>Schlagwort:</h3>
|
||||||
<div class="multi-filter">
|
<div class="multi-filter">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="filter3-1">Wert 1:</label>
|
<label for="filter3-1">Wert 1:</label>
|
||||||
@@ -921,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>
|
||||||
@@ -1003,8 +1036,8 @@
|
|||||||
// Quagga2 engine state management properties
|
// Quagga2 engine state management properties
|
||||||
let scannerRunning = false;
|
let scannerRunning = false;
|
||||||
let activeScannerCallback = null; // Dynamically handles routing data to the active caller field
|
let activeScannerCallback = null; // Dynamically handles routing data to the active caller field
|
||||||
let code4LastScanned = '';
|
var code4LastScanned = '';
|
||||||
let code4LastScannedAt = 0;
|
var code4LastScannedAt = 0;
|
||||||
|
|
||||||
function setCode4ScanStatus(message, isError = false) {
|
function setCode4ScanStatus(message, isError = false) {
|
||||||
const statusEl = document.getElementById('code4-scan-status');
|
const statusEl = document.getElementById('code4-scan-status');
|
||||||
@@ -1063,9 +1096,9 @@
|
|||||||
Quagga.stop();
|
Quagga.stop();
|
||||||
scannerRunning = false;
|
scannerRunning = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
activeScannerCallback = activeCallback;
|
activeScannerCallback = activeCallback;
|
||||||
|
|
||||||
Quagga.init({
|
Quagga.init({
|
||||||
inputStream: {
|
inputStream: {
|
||||||
name: "Live",
|
name: "Live",
|
||||||
@@ -1086,36 +1119,36 @@
|
|||||||
errorStatusSetter("Kamera konnte nicht gestartet werden.", true);
|
errorStatusSetter("Kamera konnte nicht gestartet werden.", true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Quagga.start();
|
Quagga.start();
|
||||||
scannerRunning = true;
|
scannerRunning = true;
|
||||||
errorStatusSetter(completionMsg, false);
|
errorStatusSetter(completionMsg, false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function killScannerHardware() {
|
function killScannerHardware() {
|
||||||
if (!scannerRunning) return;
|
if (!scannerRunning) return;
|
||||||
Quagga.stop();
|
Quagga.stop();
|
||||||
scannerRunning = false;
|
scannerRunning = false;
|
||||||
activeScannerCallback = null;
|
activeScannerCallback = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unified tracking capture listener
|
// Unified tracking capture listener
|
||||||
Quagga.onDetected(function(data) {
|
Quagga.onDetected(function(data) {
|
||||||
if (!data || !data.codeResult || !data.codeResult.code) return;
|
if (!data || !data.codeResult || !data.codeResult.code) return;
|
||||||
|
|
||||||
const rawBarcode = String(data.codeResult.code || '').trim();
|
const rawBarcode = String(data.codeResult.code || '').trim();
|
||||||
const currentTargetCallback = activeScannerCallback;
|
const currentTargetCallback = activeScannerCallback;
|
||||||
|
|
||||||
if (typeof currentTargetCallback === "function") {
|
if (typeof currentTargetCallback === "function") {
|
||||||
currentTargetCallback(rawBarcode);
|
currentTargetCallback(rawBarcode);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Globale Variablen für den Scanner-Status
|
// Globale Variablen für den Scanner-Status
|
||||||
let code4LastScanned = '';
|
var code4LastScanned = '';
|
||||||
let code4LastScannedAt = 0;
|
var code4LastScannedAt = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schaltet das UI basierend auf dem ausgewählten Modus um
|
* Schaltet das UI basierend auf dem ausgewählten Modus um
|
||||||
*/
|
*/
|
||||||
@@ -1125,7 +1158,6 @@
|
|||||||
const scanButton = document.getElementById('scan-code4-btn');
|
const scanButton = document.getElementById('scan-code4-btn');
|
||||||
const scannerBox = document.getElementById('code4-scanner');
|
const scannerBox = document.getElementById('code4-scanner');
|
||||||
|
|
||||||
// Scanner stoppen, falls der Modus gewechselt wird und er noch läuft
|
|
||||||
if (scannerBox && scannerBox.style.display !== 'none') {
|
if (scannerBox && scannerBox.style.display !== 'none') {
|
||||||
if (typeof killScannerHardware === 'function') killScannerHardware();
|
if (typeof killScannerHardware === 'function') killScannerHardware();
|
||||||
scannerBox.style.display = 'none';
|
scannerBox.style.display = 'none';
|
||||||
@@ -1133,15 +1165,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'range') {
|
if (mode === 'range') {
|
||||||
rangeGroup.style.display = 'block';
|
if(rangeGroup) rangeGroup.style.display = 'block';
|
||||||
if(scanButton) scanButton.style.display = 'none'; // Verstecke Scanner im Range-Modus
|
if(scanButton) scanButton.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
rangeGroup.style.none = 'none';
|
if(rangeGroup) rangeGroup.style.display = 'none'; // Hier wurde der Fehler korrigiert
|
||||||
if(rangeGroup) rangeGroup.style.display = 'none';
|
|
||||||
if(scanButton) scanButton.style.display = 'inline-block';
|
if(scanButton) scanButton.style.display = 'inline-block';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generiert eine Liste von Codes basierend auf den Eingaben
|
* Generiert eine Liste von Codes basierend auf den Eingaben
|
||||||
*/
|
*/
|
||||||
@@ -1149,42 +1180,42 @@
|
|||||||
const prefix = document.getElementById('range_prefix').value.trim();
|
const prefix = document.getElementById('range_prefix').value.trim();
|
||||||
const startStr = document.getElementById('range_start').value.trim();
|
const startStr = document.getElementById('range_start').value.trim();
|
||||||
const endStr = document.getElementById('range_end').value.trim();
|
const endStr = document.getElementById('range_end').value.trim();
|
||||||
|
|
||||||
const start = parseInt(startStr, 10);
|
const start = parseInt(startStr, 10);
|
||||||
const end = parseInt(endStr, 10);
|
const end = parseInt(endStr, 10);
|
||||||
|
|
||||||
const paddingLength = endStr.length;
|
const paddingLength = endStr.length;
|
||||||
|
|
||||||
if (isNaN(start) || isNaN(end) || start > end) {
|
if (isNaN(start) || isNaN(end) || start > end) {
|
||||||
alert("Bitte gültige Zahlen für Start und Ende eingeben. Der Startwert darf nicht größer als der Endwert sein.");
|
alert("Bitte gültige Zahlen für Start und Ende eingeben. Der Startwert darf nicht größer als der Endwert sein.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let generatedCodes = [];
|
let generatedCodes = [];
|
||||||
for (let i = start; i <= end; i++) {
|
for (let i = start; i <= end; i++) {
|
||||||
let numStr = i.toString().padStart(paddingLength, '0');
|
let numStr = i.toString().padStart(paddingLength, '0');
|
||||||
generatedCodes.push(`${prefix}${numStr}`);
|
generatedCodes.push(`${prefix}${numStr}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const codeField = document.getElementById('code_4');
|
const codeField = document.getElementById('code_4');
|
||||||
const individualCodesArea = document.getElementById('individual_codes');
|
const individualCodesArea = document.getElementById('individual_codes');
|
||||||
|
|
||||||
if (generatedCodes.length > 0) {
|
if (generatedCodes.length > 0) {
|
||||||
if (!codeField.value || confirm("Soll der aktuelle Basis-Code überschrieben werden?")) {
|
if (!codeField.value || confirm("Soll der aktuelle Basis-Code überschrieben werden?")) {
|
||||||
codeField.value = generatedCodes[0];
|
codeField.value = generatedCodes[0];
|
||||||
if (typeof validateCodeField === 'function') validateCodeField(codeField);
|
if (typeof validateCodeField === 'function') validateCodeField(codeField);
|
||||||
generatedCodes.shift();
|
generatedCodes.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (generatedCodes.length > 0) {
|
if (generatedCodes.length > 0) {
|
||||||
let existing = individualCodesArea.value.trim();
|
let existing = individualCodesArea.value.trim();
|
||||||
individualCodesArea.value = existing ? existing + '\n' + generatedCodes.join('\n') : generatedCodes.join('\n');
|
individualCodesArea.value = existing ? existing + '\n' + generatedCodes.join('\n') : generatedCodes.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
alert(`Bereich erfolgreich generiert! (${end - start + 1} Codes)`);
|
alert(`Bereich erfolgreich generiert! (${end - start + 1} Codes)`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scanner-Funktion (Single & Continuous Modus)
|
* Scanner-Funktion (Single & Continuous Modus)
|
||||||
*/
|
*/
|
||||||
@@ -1194,9 +1225,9 @@
|
|||||||
const codeField = document.getElementById('code_4');
|
const codeField = document.getElementById('code_4');
|
||||||
const individualCodesArea = document.getElementById('individual_codes');
|
const individualCodesArea = document.getElementById('individual_codes');
|
||||||
const scanMode = document.getElementById('scan_mode').value;
|
const scanMode = document.getElementById('scan_mode').value;
|
||||||
|
|
||||||
if (!scannerBox || !scanButton || !codeField) return;
|
if (!scannerBox || !scanButton || !codeField) return;
|
||||||
|
|
||||||
// Beende evtl. laufenden ISBN Scanner
|
// Beende evtl. laufenden ISBN Scanner
|
||||||
const isbnScannerBox = document.getElementById('isbn-scanner');
|
const isbnScannerBox = document.getElementById('isbn-scanner');
|
||||||
if (isbnScannerBox && isbnScannerBox.style.display !== 'none') {
|
if (isbnScannerBox && isbnScannerBox.style.display !== 'none') {
|
||||||
@@ -1206,7 +1237,7 @@
|
|||||||
if (isbnScanButton) isbnScanButton.textContent = 'Barcode scannen';
|
if (isbnScanButton) isbnScanButton.textContent = 'Barcode scannen';
|
||||||
if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus('Scanner gestoppt.');
|
if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus('Scanner gestoppt.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scanner ausschalten, wenn er bereits läuft
|
// Scanner ausschalten, wenn er bereits läuft
|
||||||
if (scannerBox.style.display !== 'none') {
|
if (scannerBox.style.display !== 'none') {
|
||||||
killScannerHardware();
|
killScannerHardware();
|
||||||
@@ -1215,12 +1246,12 @@
|
|||||||
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus('Scanner gestoppt.');
|
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus('Scanner gestoppt.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scanner starten
|
// Scanner starten
|
||||||
scannerBox.style.display = 'block';
|
scannerBox.style.display = 'block';
|
||||||
scanButton.textContent = scanMode === 'continuous' ? 'Fortlaufenden Scan stoppen' : 'Scanner stoppen';
|
scanButton.textContent = scanMode === 'continuous' ? 'Fortlaufenden Scan stoppen' : 'Scanner stoppen';
|
||||||
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus('Scanner initialisiert. Richte die Kamera auf den Barcode...');
|
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus('Scanner initialisiert. Richte die Kamera auf den Barcode...');
|
||||||
|
|
||||||
if (typeof runEngineInitialization === 'function') {
|
if (typeof runEngineInitialization === 'function') {
|
||||||
runEngineInitialization(
|
runEngineInitialization(
|
||||||
'#code4-scanner',
|
'#code4-scanner',
|
||||||
@@ -1240,10 +1271,10 @@
|
|||||||
codeField.value = decodedText;
|
codeField.value = decodedText;
|
||||||
if (typeof validateCodeField === 'function') validateCodeField(codeField);
|
if (typeof validateCodeField === 'function') validateCodeField(codeField);
|
||||||
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus(`Code gesetzt: ${decodedText}`);
|
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus(`Code gesetzt: ${decodedText}`);
|
||||||
|
|
||||||
} else if (scanMode === 'continuous') {
|
} else if (scanMode === 'continuous') {
|
||||||
let currentAreaCodes = individualCodesArea.value.split('\n').map(c => c.trim()).filter(c => c !== '');
|
let currentAreaCodes = individualCodesArea.value.split('\n').map(c => c.trim()).filter(c => c !== '');
|
||||||
|
|
||||||
if (!codeField.value) {
|
if (!codeField.value) {
|
||||||
codeField.value = decodedText;
|
codeField.value = decodedText;
|
||||||
if (typeof validateCodeField === 'function') validateCodeField(codeField);
|
if (typeof validateCodeField === 'function') validateCodeField(codeField);
|
||||||
@@ -1251,7 +1282,7 @@
|
|||||||
} else if (!currentAreaCodes.includes(decodedText) && codeField.value !== decodedText) {
|
} else if (!currentAreaCodes.includes(decodedText) && codeField.value !== decodedText) {
|
||||||
individualCodesArea.value += (individualCodesArea.value ? '\n' : '') + decodedText;
|
individualCodesArea.value += (individualCodesArea.value ? '\n' : '') + decodedText;
|
||||||
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus(`Zusatz-Code erfasst: ${decodedText}`);
|
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus(`Zusatz-Code erfasst: ${decodedText}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
const oscillator = audioCtx.createOscillator();
|
const oscillator = audioCtx.createOscillator();
|
||||||
@@ -1276,7 +1307,7 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ISBN Scanner-Funktion
|
* ISBN Scanner-Funktion
|
||||||
*/
|
*/
|
||||||
@@ -1289,7 +1320,7 @@
|
|||||||
const scanButton = document.getElementById('scan-isbn-btn');
|
const scanButton = document.getElementById('scan-isbn-btn');
|
||||||
const isbnField = document.getElementById('isbn');
|
const isbnField = document.getElementById('isbn');
|
||||||
if (!scannerBox || !scanButton || !isbnField) return;
|
if (!scannerBox || !scanButton || !isbnField) return;
|
||||||
|
|
||||||
const codeScannerBox = document.getElementById('code4-scanner');
|
const codeScannerBox = document.getElementById('code4-scanner');
|
||||||
const codeScanButton = document.getElementById('scan-code4-btn');
|
const codeScanButton = document.getElementById('scan-code4-btn');
|
||||||
if (codeScannerBox && codeScannerBox.style.display !== 'none') {
|
if (codeScannerBox && codeScannerBox.style.display !== 'none') {
|
||||||
@@ -1298,7 +1329,7 @@
|
|||||||
codeScanButton.textContent = 'Barcode scannen';
|
codeScanButton.textContent = 'Barcode scannen';
|
||||||
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus('Scanner gestoppt.');
|
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus('Scanner gestoppt.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scannerBox.style.display !== 'none') {
|
if (scannerBox.style.display !== 'none') {
|
||||||
killScannerHardware();
|
killScannerHardware();
|
||||||
scannerBox.style.display = 'none';
|
scannerBox.style.display = 'none';
|
||||||
@@ -1306,21 +1337,21 @@
|
|||||||
if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus('Scanner gestoppt.');
|
if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus('Scanner gestoppt.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
scannerBox.style.display = 'block';
|
scannerBox.style.display = 'block';
|
||||||
scanButton.textContent = 'Scanner stoppen';
|
scanButton.textContent = 'Scanner stoppen';
|
||||||
|
|
||||||
runEngineInitialization(
|
runEngineInitialization(
|
||||||
'#isbn-scanner',
|
'#isbn-scanner',
|
||||||
function(decodedText) {
|
function(decodedText) {
|
||||||
killScannerHardware();
|
killScannerHardware();
|
||||||
scannerBox.style.display = 'none';
|
scannerBox.style.display = 'none';
|
||||||
scanButton.textContent = 'Barcode scannen';
|
scanButton.textContent = 'Barcode scannen';
|
||||||
|
|
||||||
const normalizedIsbn = typeof normalizeIsbnClient === 'function' ? normalizeIsbnClient(decodedText) : decodedText;
|
const normalizedIsbn = typeof normalizeIsbnClient === 'function' ? normalizeIsbnClient(decodedText) : decodedText;
|
||||||
isbnField.value = normalizedIsbn || decodedText;
|
isbnField.value = normalizedIsbn || decodedText;
|
||||||
if (typeof updateIsbnLiveValidation === 'function') updateIsbnLiveValidation();
|
if (typeof updateIsbnLiveValidation === 'function') updateIsbnLiveValidation();
|
||||||
|
|
||||||
if (normalizedIsbn) {
|
if (normalizedIsbn) {
|
||||||
if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus(`ISBN gesetzt: ${normalizedIsbn}`);
|
if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus(`ISBN gesetzt: ${normalizedIsbn}`);
|
||||||
if (typeof fetchBookInfo === 'function') fetchBookInfo('upload');
|
if (typeof fetchBookInfo === 'function') fetchBookInfo('upload');
|
||||||
@@ -1332,7 +1363,7 @@
|
|||||||
setIsbnScanStatus
|
setIsbnScanStatus
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- WICHTIG: Event Listener Binden sobald DOM geladen ist ---
|
// --- WICHTIG: Event Listener Binden sobald DOM geladen ist ---
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// 1. Basis-Code Scanner Button aktivieren
|
// 1. Basis-Code Scanner Button aktivieren
|
||||||
@@ -1340,19 +1371,19 @@
|
|||||||
if (scanCode4Btn) {
|
if (scanCode4Btn) {
|
||||||
scanCode4Btn.addEventListener('click', startCode4Scanner);
|
scanCode4Btn.addEventListener('click', startCode4Scanner);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. ISBN Scanner Button aktivieren
|
// 2. ISBN Scanner Button aktivieren
|
||||||
const scanIsbnBtn = document.getElementById('scan-isbn-btn');
|
const scanIsbnBtn = document.getElementById('scan-isbn-btn');
|
||||||
if (scanIsbnBtn) {
|
if (scanIsbnBtn) {
|
||||||
scanIsbnBtn.addEventListener('click', startIsbnScanner);
|
scanIsbnBtn.addEventListener('click', startIsbnScanner);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Dropdown-Modus Wechsel überwachen
|
// 3. Dropdown-Modus Wechsel überwachen
|
||||||
const scanModeSelect = document.getElementById('scan_mode');
|
const scanModeSelect = document.getElementById('scan_mode');
|
||||||
if (scanModeSelect) {
|
if (scanModeSelect) {
|
||||||
scanModeSelect.addEventListener('change', toggleScanMode);
|
scanModeSelect.addEventListener('change', toggleScanMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. ISBN Live-Validierung
|
// 4. ISBN Live-Validierung
|
||||||
const isbnInput = document.getElementById('isbn');
|
const isbnInput = document.getElementById('isbn');
|
||||||
if (isbnInput && typeof updateIsbnLiveValidation === 'function') {
|
if (isbnInput && typeof updateIsbnLiveValidation === 'function') {
|
||||||
@@ -1544,6 +1575,7 @@
|
|||||||
// Get form fields
|
// Get form fields
|
||||||
const nameField = document.getElementById('name');
|
const nameField = document.getElementById('name');
|
||||||
const descriptionField = document.getElementById('beschreibung');
|
const descriptionField = document.getElementById('beschreibung');
|
||||||
|
const priceField = document.getElementById('anschaffungskosten'); // <-- NEU
|
||||||
|
|
||||||
if (!nameField || !descriptionField) {
|
if (!nameField || !descriptionField) {
|
||||||
alert('Fehler: Formularfelder nicht gefunden.');
|
alert('Fehler: Formularfelder nicht gefunden.');
|
||||||
@@ -1581,6 +1613,13 @@
|
|||||||
nameField.value = bookTitle;
|
nameField.value = bookTitle;
|
||||||
descriptionField.value = description;
|
descriptionField.value = description;
|
||||||
|
|
||||||
|
// Preis in das Formularfeld eintragen
|
||||||
|
if (priceField && currentBookData.price !== null && currentBookData.price !== undefined) {
|
||||||
|
// Wandelt den Float (z.B. 12.25) in einen String mit Komma (12,25) um
|
||||||
|
let formattedPrice = currentBookData.price.toString().replace('.', ',');
|
||||||
|
priceField.value = formattedPrice;
|
||||||
|
}
|
||||||
|
|
||||||
// Download and import book cover image if available
|
// Download and import book cover image if available
|
||||||
if (currentBookData.thumbnail) {
|
if (currentBookData.thumbnail) {
|
||||||
downloadBookCover(currentBookData.thumbnail);
|
downloadBookCover(currentBookData.thumbnail);
|
||||||
@@ -1884,16 +1923,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const scanCodeBtn = document.getElementById('scan-code4-btn');
|
|
||||||
if (scanCodeBtn) {
|
|
||||||
scanCodeBtn.addEventListener('click', startCode4Scanner);
|
|
||||||
}
|
|
||||||
|
|
||||||
const scanIsbnBtn = document.getElementById('scan-isbn-btn');
|
|
||||||
if (scanIsbnBtn) {
|
|
||||||
scanIsbnBtn.addEventListener('click', startIsbnScanner);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isbnField = document.getElementById('isbn');
|
const isbnField = document.getElementById('isbn');
|
||||||
if (isbnField) {
|
if (isbnField) {
|
||||||
isbnField.addEventListener('input', updateIsbnLiveValidation);
|
isbnField.addEventListener('input', updateIsbnLiveValidation);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+109
-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,8 @@ 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},
|
||||||
}
|
}
|
||||||
existing['trial'] = trial_config
|
existing['trial'] = trial_config
|
||||||
|
|
||||||
@@ -244,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
|
||||||
@@ -257,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,
|
||||||
@@ -293,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",
|
||||||
@@ -318,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() {
|
||||||
@@ -434,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")" )
|
||||||
@@ -445,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" )
|
||||||
@@ -455,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
|
||||||
@@ -565,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."
|
||||||
@@ -597,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
|
||||||
@@ -690,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
|
||||||
@@ -712,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)
|
||||||
@@ -756,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."
|
||||||
@@ -832,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'
|
||||||
@@ -913,4 +886,4 @@ PY
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
@@ -15,3 +15,4 @@ openpyxl
|
|||||||
cryptography>=42.0.0
|
cryptography>=42.0.0
|
||||||
pywebpush
|
pywebpush
|
||||||
py-vapid>=1.9.0
|
py-vapid>=1.9.0
|
||||||
|
beautifulsoup4
|
||||||
Reference in New Issue
Block a user