Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1122618a51 | |||
| 54d56fc463 | |||
| 9da4ff3ec8 | |||
| 884464cfe5 | |||
| 0be4e3ca58 | |||
| e2aeea46f9 | |||
| 865fddd45b | |||
| 1835195d2f | |||
| 19a585b2ec | |||
| 925f07e96f | |||
| b8beec8209 | |||
| dd9390c649 | |||
| f31c4e2ff7 | |||
| 71e2895362 | |||
| d2c7e57f8d | |||
| 111d40b787 | |||
| 1a9513d442 | |||
| 3900059eb1 | |||
| 07b1cc5e79 | |||
| 6583f7a368 | |||
| 8e11fbf969 | |||
| 7d13dc264c | |||
| 480bd00a54 | |||
| fdf699363a | |||
| dd495a8b13 | |||
| ca57961d4b | |||
| 3923939387 | |||
| eb9c655398 | |||
| 4c2657218d | |||
| 875cfa01a3 | |||
| c0c61a41a3 | |||
| 9e74d0c16d | |||
| c8818edf0b | |||
| b270bc9367 | |||
| e1c284fc40 | |||
| debd4dceb1 | |||
| db15df57c3 | |||
| bcfe2095d5 | |||
| 69ac6eca63 | |||
| 717e3ce15e | |||
| 007ae04bf3 | |||
| c0947c5cf1 | |||
| c1d2935ad6 | |||
| 9fde0b4b2a | |||
| da7b075cc4 | |||
| 347f258b8f | |||
| 2421f867ed | |||
| 921915e92f | |||
| a4b0866293 | |||
| e10d86da4b | |||
| f160f23e9c | |||
| ca272542de | |||
| 7df9b65389 | |||
| e4af76c9c1 | |||
| f5944bf090 | |||
| 4e68da2368 | |||
| 0d6aca4e8c | |||
| 2bb0cbe599 | |||
| 339487b4d4 | |||
| 3f90e2d4f1 | |||
| b238b52fbf | |||
| 545dd7783c |
+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
|
||||||
|
|||||||
+182
-27
@@ -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
|
||||||
|
|
||||||
@@ -1735,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)
|
||||||
@@ -1995,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'],
|
||||||
@@ -3301,7 +3302,13 @@ def api_library_items():
|
|||||||
borrower = doc.get('User', '')
|
borrower = doc.get('User', '')
|
||||||
# Decrypt if value is encrypted (decrypt_text returns original if not encrypted)
|
# Decrypt if value is encrypted (decrypt_text returns original if not encrypted)
|
||||||
try:
|
try:
|
||||||
borrower = decrypt_text(borrower) if borrower else ''
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
|
db = client[cfg.MONGODB_DB]
|
||||||
|
student_cards = db['student_cards']
|
||||||
|
card = student_cards.find_one({'_id': ObjectId(borrow_id)})
|
||||||
|
card = _decrypt_student_card_doc(card)
|
||||||
|
client.close()
|
||||||
|
borrower = card['SchülerName']
|
||||||
except Exception:
|
except Exception:
|
||||||
# Fallback: keep original string if decryption fails
|
# Fallback: keep original string if decryption fails
|
||||||
borrower = borrower or ''
|
borrower = borrower or ''
|
||||||
@@ -3820,7 +3827,7 @@ def library_admin():
|
|||||||
show_library_features=True,
|
show_library_features=True,
|
||||||
upload_mode='library',
|
upload_mode='library',
|
||||||
page_title='Bücher hochladen',
|
page_title='Bücher hochladen',
|
||||||
back_target='home_admin'
|
back_target='library'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -4123,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)
|
||||||
@@ -4150,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)
|
||||||
@@ -4290,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)
|
||||||
@@ -5064,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:
|
||||||
@@ -5077,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', '')
|
||||||
@@ -5106,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
|
||||||
@@ -5185,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'):
|
||||||
@@ -6031,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:
|
||||||
@@ -8363,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', '')
|
||||||
|
|
||||||
@@ -9539,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):
|
||||||
"""
|
"""
|
||||||
@@ -9562,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
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -10130,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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+156
-77
@@ -18,9 +18,11 @@ Collection Structure:
|
|||||||
- Status fields: slots_used_by
|
- Status fields: slots_used_by
|
||||||
"""
|
"""
|
||||||
import Web.modules.database.settings as cfg
|
import Web.modules.database.settings as cfg
|
||||||
|
import Web.modules.inventarsystem.data_protection as dp
|
||||||
from Web.modules.database.settings import MongoClient
|
from Web.modules.database.settings import MongoClient
|
||||||
from bson.objectid import ObjectId
|
from bson.objectid import ObjectId
|
||||||
import datetime
|
import datetime
|
||||||
|
import ast
|
||||||
|
|
||||||
|
|
||||||
def _get_tenant_db(client):
|
def _get_tenant_db(client):
|
||||||
@@ -37,8 +39,47 @@ def _active_record_query(extra_query=None):
|
|||||||
base_query.update(extra_query)
|
base_query.update(extra_query)
|
||||||
return base_query
|
return base_query
|
||||||
|
|
||||||
|
def _decrypt_appointment(item):
|
||||||
|
"""Helper function to safely decrypt appointment fields back to their original types."""
|
||||||
|
if not item:
|
||||||
|
return item
|
||||||
|
|
||||||
|
try:
|
||||||
|
if 'user' in item and item['user']:
|
||||||
|
item['user'] = dp.decrypt_text(item['user'])
|
||||||
|
|
||||||
|
if 'note' in item and item['note']:
|
||||||
|
item['note'] = dp.decrypt_text(item['note'])
|
||||||
|
|
||||||
|
if 'title' in item and item['title']:
|
||||||
|
item['title'] = dp.decrypt_text(item['title'])
|
||||||
|
|
||||||
|
if 'mail' in item and item['mail']:
|
||||||
|
decrypted_mail = dp.decrypt_text(item['mail'])
|
||||||
|
try:
|
||||||
|
item['mail'] = ast.literal_eval(decrypted_mail)
|
||||||
|
except Exception:
|
||||||
|
item['mail'] = decrypted_mail
|
||||||
|
|
||||||
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=""):
|
if 'custom_fields' in item and item['custom_fields']:
|
||||||
|
item['custom_fields'] = [dp.decrypt_text(field) for field in item['custom_fields']]
|
||||||
|
|
||||||
|
if 'slots_booked' in item and item['slots_booked']:
|
||||||
|
# If it's a string, it was encrypted during an update execution
|
||||||
|
if isinstance(item['slots_booked'], str):
|
||||||
|
decrypted_slots = dp.decrypt_text(item['slots_booked'])
|
||||||
|
try:
|
||||||
|
item['slots_booked'] = ast.literal_eval(decrypted_slots)
|
||||||
|
except Exception:
|
||||||
|
item['slots_booked'] = decrypted_slots
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error during decryption: {e}")
|
||||||
|
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False, title: str="", custom_fields: list = (), clients_p_slot: int=1):
|
||||||
|
client = None
|
||||||
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)
|
||||||
@@ -50,60 +91,53 @@ def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
|
|||||||
'time_span': time_span,
|
'time_span': time_span,
|
||||||
'slots': slots,
|
'slots': slots,
|
||||||
'slot_lenght': slot_lenght,
|
'slot_lenght': slot_lenght,
|
||||||
'user': user,
|
'user': dp.encrypt_text(user.strip()),
|
||||||
'mail': mail,
|
'mail': dp.encrypt_text(str(mail)),
|
||||||
'note': note,
|
'note': dp.encrypt_text(note),
|
||||||
'title': title,
|
'title': dp.encrypt_text(title),
|
||||||
|
'custom_fields': [dp.encrypt_text(str(field)) for field in custom_fields],
|
||||||
'calendar_enabled': bool(calendar_enabled),
|
'calendar_enabled': bool(calendar_enabled),
|
||||||
'slots_booked': [], # -> [(start_time, name), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable
|
'clients_per_slot': clients_p_slot,
|
||||||
|
'slots_booked': [], # -> [(start_time, (names),(custom1, custom2,...)), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable
|
||||||
'Created': datetime.datetime.now(),
|
'Created': datetime.datetime.now(),
|
||||||
'LastUpdated': datetime.datetime.now()
|
'LastUpdated': datetime.datetime.now()
|
||||||
}
|
}
|
||||||
result = items.insert_one(item)
|
result = items.insert_one(item)
|
||||||
return result.inserted_id
|
return result.inserted_id
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Exception accured: {e}")
|
print(f"Exception occurred in add: {e}")
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
if client:
|
||||||
|
client.close()
|
||||||
|
|
||||||
def get_item(id):
|
def get_item(id):
|
||||||
"""
|
"""Retrieve a specific appointment by its ID and decrypt it."""
|
||||||
Retrieve a specific appointment by its ID.
|
client = None
|
||||||
|
|
||||||
Args:
|
|
||||||
id (str): ID of the appointsment to retrieve
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: The appointment document or None if not found
|
|
||||||
"""
|
|
||||||
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)
|
||||||
items = db['appointments']
|
items = db['appointments']
|
||||||
item = items.find_one(_active_record_query({'_id': ObjectId(id)}))
|
item = items.find_one(_active_record_query({'_id': ObjectId(id)}))
|
||||||
client.close()
|
|
||||||
return item
|
return _decrypt_appointment(item)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error retrieving item: {e}")
|
print(f"Error retrieving item: {e}")
|
||||||
return None
|
return None
|
||||||
|
finally:
|
||||||
|
if client:
|
||||||
|
client.close()
|
||||||
|
|
||||||
def update(id,slots_used: list):
|
def update(id, slots_used: list):
|
||||||
"""
|
"""Update an existing appointment's booked slots securely."""
|
||||||
Update an existing appointment.
|
client = None
|
||||||
|
|
||||||
Args:
|
|
||||||
id (str): ID of the item to update
|
|
||||||
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if successful, False otherwise
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = _get_tenant_db(client)
|
db = _get_tenant_db(client)
|
||||||
items = db['appointments']
|
items = db['appointments']
|
||||||
|
|
||||||
update_data = {
|
update_data = {
|
||||||
'slots_booked': slots_used,
|
'slots_booked': dp.encrypt_text(str(slots_used)),
|
||||||
'LastUpdated': datetime.datetime.now()
|
'LastUpdated': datetime.datetime.now()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,53 +146,81 @@ def update(id,slots_used: list):
|
|||||||
{'$set': update_data}
|
{'$set': update_data}
|
||||||
)
|
)
|
||||||
|
|
||||||
client.close()
|
|
||||||
return result.modified_count > 0
|
return result.modified_count > 0
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error updating item: {e}")
|
print(f"Error updating item: {e}")
|
||||||
return False
|
return False
|
||||||
|
finally:
|
||||||
|
if client:
|
||||||
|
client.close()
|
||||||
|
|
||||||
def remove_slot(id, date_start_time, name):
|
def remove_slot(id, date_start_time, name):
|
||||||
"""
|
"""
|
||||||
Remove a booked slot from an appointment's `slots_booked`.
|
Remove a booked slot from an appointment's encrypted `slots_booked` list.
|
||||||
|
|
||||||
Args:
|
Because the array is stored as an encrypted string blob, we must decrypt,
|
||||||
id (str): Appointment ID
|
modify it in Python, and re-encrypt it.
|
||||||
date_start_time: The start time value used when booking
|
|
||||||
name (str): Name associated with the booking
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if a slot was removed, False otherwise
|
|
||||||
"""
|
"""
|
||||||
|
client = None
|
||||||
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)
|
||||||
items = db['appointments']
|
items = db['appointments']
|
||||||
|
|
||||||
# Attempt to pull the exact element (stored as an array/tuple)
|
item = items.find_one({'_id': ObjectId(id)})
|
||||||
|
if not item or 'slots_booked' not in item or not item['slots_booked']:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
decrypted_slots = dp.decrypt_text(item['slots_booked'])
|
||||||
|
slots_list = ast.literal_eval(decrypted_slots)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to decrypt or parse slots: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Structure format note: [(start_time, (names), (custom1, custom2...)), ...]
|
||||||
|
updated_slots = []
|
||||||
|
removed_any = False
|
||||||
|
|
||||||
|
for slot in slots_list:
|
||||||
|
slot_start = slot[0]
|
||||||
|
slot_names = slot[1]
|
||||||
|
|
||||||
|
|
||||||
|
if slot_start == date_start_time and (slot_names == name or name in slot_names):
|
||||||
|
removed_any = True
|
||||||
|
continue
|
||||||
|
updated_slots.append(slot)
|
||||||
|
|
||||||
|
if not removed_any:
|
||||||
|
return False
|
||||||
|
|
||||||
result = items.update_one(
|
result = items.update_one(
|
||||||
{'_id': ObjectId(id)},
|
{'_id': ObjectId(id)},
|
||||||
{'$pull': {'slots_booked': [date_start_time, name]}}
|
{
|
||||||
|
'$set': {
|
||||||
|
'slots_booked': dp.encrypt_text(str(updated_slots)),
|
||||||
|
'LastUpdated': datetime.datetime.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
client.close()
|
|
||||||
return result.modified_count > 0
|
return result.modified_count > 0
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error removing slot: {e}")
|
print(f"Error removing slot: {e}")
|
||||||
return False
|
return False
|
||||||
|
finally:
|
||||||
|
if client:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
def remove(id):
|
def remove(id):
|
||||||
"""
|
"""
|
||||||
Soft-delete an appointment by setting its `Deleted` flag.
|
Hard-delete an appointment plan by its ID.
|
||||||
|
(Note: If your docstring mentions a soft-delete 'Deleted' flag,
|
||||||
Args:
|
change items.delete_one to items.update_one with {'$set': {'Deleted': True}})
|
||||||
id (str): Appointment ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if the appointment was marked deleted, False otherwise
|
|
||||||
"""
|
"""
|
||||||
|
client = None
|
||||||
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)
|
||||||
@@ -166,70 +228,87 @@ def remove(id):
|
|||||||
|
|
||||||
result = items.delete_one({'_id': ObjectId(id)})
|
result = items.delete_one({'_id': ObjectId(id)})
|
||||||
|
|
||||||
client.close()
|
|
||||||
return result.deleted_count > 0
|
return result.deleted_count > 0
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error removing appointment: {e}")
|
print(f"Error removing appointment: {e}")
|
||||||
return False
|
return False
|
||||||
|
finally:
|
||||||
|
if client:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
def remove_done():
|
def remove_done():
|
||||||
"""removose already finisched appointments"""
|
"""Remove all expired appointments whose end date is prior to today in a single call."""
|
||||||
|
client = None
|
||||||
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)
|
||||||
items = db['appointments']
|
items = db['appointments']
|
||||||
|
|
||||||
today = datetime.date.today().strftime('%Y-%m-%d')
|
today = datetime.date.today().strftime('%Y-%m-%d')
|
||||||
removed_count = 0
|
|
||||||
|
|
||||||
cursor = items.find(
|
result = items.delete_many(
|
||||||
_active_record_query(
|
_active_record_query(
|
||||||
{
|
{
|
||||||
'date_end': {'$lt': today},
|
'date_end': {'$lt': today},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
).sort('date_start', 1)
|
)
|
||||||
|
|
||||||
for item in cursor:
|
return result.deleted_count > 0
|
||||||
item['_id'] = str(item.get('_id'))
|
|
||||||
result = items.delete_one({'_id': ObjectId(item['_id'])})
|
|
||||||
removed_count += result.deleted_count
|
|
||||||
|
|
||||||
client.close()
|
|
||||||
return removed_count > 0
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error removing appointment: {e}")
|
print(f"Error cleaning up finished appointments: {e}")
|
||||||
return False
|
return False
|
||||||
|
finally:
|
||||||
|
if client:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
def get_upcoming_for_user(user: str, limit: int = 25):
|
def get_upcoming_for_user(user: str, limit: int = 25):
|
||||||
"""Return upcoming appointment plans for a user ordered by start date."""
|
"""
|
||||||
remove_done()
|
Return upcoming appointment plans for a user, handling encrypted database records.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if hasattr(globals(), 'remove_done'):
|
||||||
|
remove_done()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
client = None
|
||||||
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)
|
||||||
items = db['appointments']
|
items = db['appointments']
|
||||||
|
|
||||||
today = datetime.date.today().strftime('%Y-%m-%d')
|
today = datetime.date.today().strftime('%Y-%m-%d')
|
||||||
|
target_user = str(user or '').strip()
|
||||||
|
|
||||||
cursor = items.find(
|
cursor = items.find(
|
||||||
_active_record_query(
|
_active_record_query({
|
||||||
{
|
'date_end': {'$gte': today},
|
||||||
'user': str(user or '').strip(),
|
})
|
||||||
'date_end': {'$gte': today},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
).sort('date_start', 1)
|
).sort('date_start', 1)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for item in cursor:
|
for item in cursor:
|
||||||
item['_id'] = str(item.get('_id'))
|
decrypted_item = _decrypt_appointment(item)
|
||||||
results.append(item)
|
if not decrypted_item:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if decrypted_item.get('user', '').strip() != target_user:
|
||||||
|
continue
|
||||||
|
|
||||||
|
decrypted_item['_id'] = str(decrypted_item.get('_id'))
|
||||||
|
results.append(decrypted_item)
|
||||||
|
|
||||||
if len(results) >= max(1, int(limit)):
|
if len(results) >= max(1, int(limit)):
|
||||||
break
|
break
|
||||||
|
|
||||||
client.close()
|
|
||||||
return results
|
return results
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error retrieving upcoming appointments: {e}")
|
print(f"Error retrieving upcoming appointments: {e}")
|
||||||
return []
|
return []
|
||||||
|
finally:
|
||||||
|
if client:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
+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):
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import Web.modules.emailservice.email as mail_service
|
|||||||
import Web.modules.database.termine as termin
|
import Web.modules.database.termine as termin
|
||||||
import Web.modules.database.settings as cfg
|
import Web.modules.database.settings as cfg
|
||||||
from Web.tenant import get_tenant_context
|
from Web.tenant import get_tenant_context
|
||||||
|
import Web.modules.inventarsystem.data_protection as dp
|
||||||
|
|
||||||
|
|
||||||
def _resolve_public_base_url() -> str:
|
def _resolve_public_base_url() -> str:
|
||||||
@@ -99,8 +100,11 @@ def build_calendar_ics(appointment_id: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
uid = f"terminplaner-{appointment_id}@invario.eu"
|
uid = f"terminplaner-{appointment_id}@invario.eu"
|
||||||
created_at = datetime.datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')
|
|
||||||
summary = f"Terminplan für {creator}"
|
created_at = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ')
|
||||||
|
|
||||||
|
summary = titel if titel else f"Terminplan für {creator}"
|
||||||
|
|
||||||
description_lines = [
|
description_lines = [
|
||||||
f"Buchungslink: {link}",
|
f"Buchungslink: {link}",
|
||||||
f"Zeitraum: {date_start} bis {date_end}",
|
f"Zeitraum: {date_start} bis {date_end}",
|
||||||
@@ -109,7 +113,7 @@ 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:
|
if titel and not summary == titel:
|
||||||
description_lines.append('Titel: ' + str(titel))
|
description_lines.append('Titel: ' + str(titel))
|
||||||
|
|
||||||
ics_lines = [
|
ics_lines = [
|
||||||
@@ -125,8 +129,7 @@ def build_calendar_ics(appointment_id: str) -> str | None:
|
|||||||
f'DESCRIPTION:{_escape_ics_text(chr(10).join(description_lines))}',
|
f'DESCRIPTION:{_escape_ics_text(chr(10).join(description_lines))}',
|
||||||
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 + datetime.timedelta(days=1))}',
|
||||||
f'Titel:{_escape_ics_text(titel)}',
|
|
||||||
'END:VEVENT',
|
'END:VEVENT',
|
||||||
'END:VCALENDAR',
|
'END:VCALENDAR',
|
||||||
'',
|
'',
|
||||||
@@ -172,7 +175,7 @@ def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str
|
|||||||
]
|
]
|
||||||
|
|
||||||
uid = f"terminplaner-slot-{appointment_id}-{start_dt.strftime('%Y%m%d%H%M')}@invario.eu"
|
uid = f"terminplaner-slot-{appointment_id}-{start_dt.strftime('%Y%m%d%H%M')}@invario.eu"
|
||||||
created_at = datetime.datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')
|
created_at = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ')
|
||||||
dt_start = start_dt.strftime('%Y%m%dT%H%M%S')
|
dt_start = start_dt.strftime('%Y%m%dT%H%M%S')
|
||||||
dt_end = end_dt.strftime('%Y%m%dT%H%M%S')
|
dt_end = end_dt.strftime('%Y%m%dT%H%M%S')
|
||||||
|
|
||||||
@@ -198,7 +201,7 @@ def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str
|
|||||||
return '\r\n'.join(ics_lines)
|
return '\r\n'.join(ics_lines)
|
||||||
|
|
||||||
|
|
||||||
def new(date_start: str, date_end: str, time_span: list, slots, slot_length, user: str, mail: list=None, note:str="", calendar_enabled: bool=False, title: str="") -> dict:
|
def new(date_start: str, date_end: str, time_span: list, slots, slot_length, user: str, mail: list=None, note:str="", calendar_enabled: bool=False, title: str="", custom_fields: list = (), clients_per_slot: int=1) -> dict:
|
||||||
"""
|
"""
|
||||||
Generates a link for the executive to send to his clients to book a time Slot
|
Generates a link for the executive to send to his clients to book a time Slot
|
||||||
"""
|
"""
|
||||||
@@ -207,6 +210,11 @@ def new(date_start: str, date_end: str, time_span: list, slots, slot_length, use
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
slots_int = 0
|
slots_int = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
custom_fields.pop(-1)
|
||||||
|
except :
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
slot_length_int = int(slot_length)
|
slot_length_int = int(slot_length)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
@@ -215,7 +223,7 @@ def new(date_start: str, date_end: str, time_span: list, slots, slot_length, use
|
|||||||
normalized_time_span = _normalize_time_span(time_span)
|
normalized_time_span = _normalize_time_span(time_span)
|
||||||
normalized_mail = _normalize_mail_list(mail or [])
|
normalized_mail = _normalize_mail_list(mail or [])
|
||||||
|
|
||||||
id = termin.add(date_start, date_end, normalized_time_span, slots_int, slot_length_int, user, normalized_mail, note, calendar_enabled=calendar_enabled, title=title)
|
id = termin.add(date_start, date_end, normalized_time_span, slots_int, slot_length_int, user, normalized_mail, note, calendar_enabled=calendar_enabled, title=title, custom_fields=custom_fields, clients_p_slot=clients_per_slot)
|
||||||
id_str = str(id)
|
id_str = str(id)
|
||||||
tenant_id = _current_tenant_id()
|
tenant_id = _current_tenant_id()
|
||||||
|
|
||||||
@@ -254,7 +262,7 @@ def new(date_start: str, date_end: str, time_span: list, slots, slot_length, use
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def book_slot(id, date_start_time, name):
|
def book_slot(id, date_start_time, name, custom: tuple = ()):
|
||||||
try:
|
try:
|
||||||
item = termin.get_item(id)
|
item = termin.get_item(id)
|
||||||
if not item:
|
if not item:
|
||||||
@@ -264,18 +272,32 @@ def book_slot(id, date_start_time, name):
|
|||||||
if not isinstance(slots, list):
|
if not isinstance(slots, list):
|
||||||
slots = []
|
slots = []
|
||||||
|
|
||||||
|
# 1. Check overall total booking capacity
|
||||||
capacity = int(item.get('slots', 0) or 0)
|
capacity = int(item.get('slots', 0) or 0)
|
||||||
if capacity and len(slots) >= capacity:
|
if capacity and len(slots) >= capacity:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# 2. Prevent the exact same user from double-booking the exact same slot
|
||||||
for existing in slots:
|
for existing in slots:
|
||||||
if isinstance(existing, (list, tuple)) and len(existing) >= 2:
|
if isinstance(existing, (list, tuple)) and len(existing) >= 2:
|
||||||
if existing[0] == date_start_time and existing[1] == name:
|
if existing[0] == date_start_time and existing[1] == name:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
slots.append((date_start_time, name))
|
# 3. Check concurrent capacity for this specific time slot
|
||||||
|
clients_per_slot = int(item.get('clients_per_slot', 1) or 1)
|
||||||
|
bookings_at_time = sum(
|
||||||
|
1 for existing in slots
|
||||||
|
if isinstance(existing, (list, tuple)) and len(existing) > 0 and existing[0] == date_start_time
|
||||||
|
)
|
||||||
|
|
||||||
|
if bookings_at_time >= clients_per_slot:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Append the new booking successfully
|
||||||
|
slots.append((date_start_time, name, custom))
|
||||||
success = termin.update(id, slots)
|
success = termin.update(id, slots)
|
||||||
return bool(success)
|
return bool(success)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error booking slot: {e}")
|
print(f"Error booking slot: {e}")
|
||||||
return False
|
return False
|
||||||
@@ -326,32 +348,46 @@ def get_available(id):
|
|||||||
date_end = termin_range.get('date_end')
|
date_end = termin_range.get('date_end')
|
||||||
time_span = termin_range.get('time_span', [])
|
time_span = termin_range.get('time_span', [])
|
||||||
|
|
||||||
|
# Safe integer parsing without heavy try-except blocks
|
||||||
try:
|
try:
|
||||||
total_slots = int(termin_range.get('slots', 0) or 0)
|
total_slots = int(termin_range.get('slots', 0) or 0)
|
||||||
except Exception:
|
except (ValueError, TypeError):
|
||||||
total_slots = 0
|
total_slots = 0
|
||||||
|
|
||||||
|
# Support both spellings gracefully
|
||||||
|
raw_length = termin_range.get('slot_length') or termin_range.get('slot_lenght') or 0
|
||||||
try:
|
try:
|
||||||
slot_length = int(termin_range.get('slot_length') or termin_range.get('slot_lenght') or 0)
|
slot_length = int(raw_length)
|
||||||
except Exception:
|
except (ValueError, TypeError):
|
||||||
slot_length = termin_range.get('slot_length') or termin_range.get('slot_lenght')
|
slot_length = raw_length
|
||||||
|
|
||||||
|
clients_per_slot = int(termin_range.get('clients_per_slot', 1) or 1)
|
||||||
booked = termin_range.get('slots_booked', []) or []
|
booked = termin_range.get('slots_booked', []) or []
|
||||||
|
|
||||||
|
# Normalize the bookings list safely
|
||||||
normalized = []
|
normalized = []
|
||||||
|
bookings_by_time = {} # Tracks how many people are in each specific time slot
|
||||||
|
|
||||||
for s in booked:
|
for s in booked:
|
||||||
if isinstance(s, (list, tuple)) and len(s) >= 2:
|
if isinstance(s, (list, tuple)) and len(s) >= 2:
|
||||||
normalized.append({'start': s[0], 'name': s[1]})
|
slot_time = s[0]
|
||||||
|
item = {'start': slot_time, 'name': s[1]}
|
||||||
elif isinstance(s, dict):
|
elif isinstance(s, dict):
|
||||||
normalized.append(s)
|
slot_time = s.get('start')
|
||||||
|
item = s
|
||||||
else:
|
else:
|
||||||
normalized.append({'value': s})
|
slot_time = str(s)
|
||||||
|
item = {'value': s}
|
||||||
|
|
||||||
|
normalized.append(item)
|
||||||
|
|
||||||
|
# Count concurrent bookings per timestamp
|
||||||
|
if slot_time:
|
||||||
|
bookings_by_time[slot_time] = bookings_by_time.get(slot_time, 0) + 1
|
||||||
|
|
||||||
|
# Calculate remaining total capacity safely
|
||||||
slots_used = len(normalized)
|
slots_used = len(normalized)
|
||||||
try:
|
slots_left = max(0, total_slots - slots_used)
|
||||||
slots_left = max(0, int(total_slots) - slots_used)
|
|
||||||
except Exception:
|
|
||||||
slots_left = max(0, slots_used - slots_used)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'date_start': date_start,
|
'date_start': date_start,
|
||||||
@@ -360,8 +396,10 @@ def get_available(id):
|
|||||||
'slot_length': slot_length,
|
'slot_length': slot_length,
|
||||||
'slot_lenght': slot_length,
|
'slot_lenght': slot_length,
|
||||||
'slots_total': total_slots,
|
'slots_total': total_slots,
|
||||||
|
'clients_per_slot': clients_per_slot,
|
||||||
'slots_booked': normalized,
|
'slots_booked': normalized,
|
||||||
'slots_left': slots_left,
|
'slots_left': slots_left,
|
||||||
|
'bookings_by_time': bookings_by_time
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error getting available slots: {e}")
|
print(f"Error getting available slots: {e}")
|
||||||
@@ -370,7 +408,6 @@ def get_available(id):
|
|||||||
def get_available_user(id):
|
def get_available_user(id):
|
||||||
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]:
|
||||||
user_name = str(user or '').strip()
|
user_name = str(user or '').strip()
|
||||||
if not user_name:
|
if not user_name:
|
||||||
@@ -418,6 +455,7 @@ def get_user_upcoming_events(user: str, limit: int = 25) -> list[dict]:
|
|||||||
'link': link,
|
'link': link,
|
||||||
'calendar_link': calendar_link if item.get('calendar_enabled') else None,
|
'calendar_link': calendar_link if item.get('calendar_enabled') else None,
|
||||||
'title': str(item.get('title') or ''),
|
'title': str(item.get('title') or ''),
|
||||||
|
'custom_fields': list(item.get('custom_fields')),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,57 @@
|
|||||||
from flask import Blueprint, render_template, request, session, url_for, redirect, flash
|
from flask import Blueprint, render_template, request, session, url_for, redirect, flash, make_response, Response, send_file
|
||||||
from flask import Response
|
|
||||||
import Web.modules.terminplaner.backend_server as appointment_service
|
import Web.modules.terminplaner.backend_server as appointment_service
|
||||||
import Web.modules.database.settings as cfg
|
import Web.modules.database.settings as cfg
|
||||||
import Web.modules.database.termine as termin
|
import Web.modules.database.termine as termin
|
||||||
import Web.modules.database.user as us
|
import Web.modules.database.user as us
|
||||||
|
from Web.modules.terminplaner.backend_server import _resolve_public_base_url
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import qrcode
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from reportlab.lib.pagesizes import A4
|
||||||
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||||
|
from reportlab.lib.units import cm
|
||||||
|
from reportlab.lib.colors import grey, HexColor
|
||||||
|
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image
|
||||||
|
|
||||||
|
|
||||||
# Create a blueprint instance
|
# Create a blueprint instance
|
||||||
appoint_bp = Blueprint('terminplaner', __name__)
|
appoint_bp = Blueprint('terminplaner', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_school_info_for_export():
|
||||||
|
"""
|
||||||
|
Get school information for PDF exports from configuration or database.
|
||||||
|
Returns default info if not configured.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if hasattr(cfg, 'get_school_info'):
|
||||||
|
return cfg.get_school_info()
|
||||||
|
|
||||||
|
school_info = {
|
||||||
|
'name': 'Schulname',
|
||||||
|
'address': 'Schuladresse',
|
||||||
|
'postal_code': 'PLZ',
|
||||||
|
'city': 'Stadt',
|
||||||
|
'school_number': '000000',
|
||||||
|
'it_admin': 'IT-Beauftragter/in',
|
||||||
|
'logo_path': '',
|
||||||
|
}
|
||||||
|
return school_info
|
||||||
|
except Exception:
|
||||||
|
# Return defaults if anything fails
|
||||||
|
return {
|
||||||
|
'name': 'Schulname',
|
||||||
|
'address': 'Schuladresse',
|
||||||
|
'postal_code': 'PLZ',
|
||||||
|
'city': 'Stadt',
|
||||||
|
'school_number': '000000',
|
||||||
|
'it_admin': 'IT-Beauftragter/in',
|
||||||
|
'logo_path': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _require_module_enabled():
|
def _require_module_enabled():
|
||||||
if not cfg.MODULES.is_enabled('terminplan'):
|
if not cfg.MODULES.is_enabled('terminplan'):
|
||||||
flash('Der Terminplaner ist deaktiviert.', 'info')
|
flash('Der Terminplaner ist deaktiviert.', 'info')
|
||||||
@@ -34,10 +77,70 @@ def _current_tenant_id():
|
|||||||
pass
|
pass
|
||||||
return str(session.get('tenant_id', '') or '').strip()
|
return str(session.get('tenant_id', '') or '').strip()
|
||||||
|
|
||||||
|
@appoint_bp.route('/client/<appointment_id>/export_csv')
|
||||||
|
def export_csv(appointment_id):
|
||||||
|
"""
|
||||||
|
Generiert einen CSV-Export aller Buchungen für den Ersteller.
|
||||||
|
"""
|
||||||
|
# 1. Berechtigungsprüfung (analog zur Client-Route)
|
||||||
|
current_user = str(session.get('username', '') or '').strip()
|
||||||
|
appointment_item = termin.get_item(appointment_id) or {}
|
||||||
|
appointment_owner = str(appointment_item.get('user', '') or '').strip()
|
||||||
|
|
||||||
|
can_view = False
|
||||||
|
if current_user:
|
||||||
|
try:
|
||||||
|
can_view = bool(us.check_admin(current_user) or current_user == appointment_owner)
|
||||||
|
except Exception:
|
||||||
|
can_view = bool(current_user == appointment_owner)
|
||||||
|
|
||||||
|
if not can_view:
|
||||||
|
flash('Nicht autorisiert für diesen Export.', 'error')
|
||||||
|
return redirect(url_for('terminplaner.client', appointment_id=appointment_id))
|
||||||
|
|
||||||
|
custom_fields = appointment_item.get('custom_fields', [])
|
||||||
|
bookings = appointment_item.get('slots_booked', []) or []
|
||||||
|
|
||||||
|
# 2. CSV im Speicher erstellen
|
||||||
|
si = io.StringIO()
|
||||||
|
# Wir nutzen ein Semikolon als Trennzeichen – das öffnet Excel im deutschsprachigen Raum direkt fehlerfrei
|
||||||
|
cw = csv.writer(si, delimiter=';', quoting=csv.QUOTE_MINIMAL)
|
||||||
|
|
||||||
|
# Tabellenkopf schreiben
|
||||||
|
headers = ['Zeitpunkt', 'Name'] + list(custom_fields)
|
||||||
|
cw.writerow(headers)
|
||||||
|
|
||||||
|
# Datenzeilen schreiben
|
||||||
|
for booking in bookings:
|
||||||
|
if isinstance(booking, (list, tuple)):
|
||||||
|
row = [booking[0], booking[1]]
|
||||||
|
|
||||||
|
# Antworten holen und sicherstellen, dass Lücken (falls vorhanden) mit Leerstrings gefüllt werden
|
||||||
|
answers = booking[2] if len(booking) > 2 else []
|
||||||
|
for i in range(len(custom_fields)):
|
||||||
|
if i < len(answers):
|
||||||
|
row.append(answers[i])
|
||||||
|
else:
|
||||||
|
row.append('')
|
||||||
|
cw.writerow(row)
|
||||||
|
|
||||||
|
# 3. Response vorbereiten und UTF-8-BOM für Excel mitsenden
|
||||||
|
response_content = si.getvalue()
|
||||||
|
output = make_response(response_content)
|
||||||
|
|
||||||
|
output.data = b'\xef\xbb\xbf' + output.data
|
||||||
|
|
||||||
|
# Dateiname generieren
|
||||||
|
title_slug = appointment_item.get('title', 'export').replace(' ', '_')
|
||||||
|
output.headers["Content-Disposition"] = f"attachment; filename=buchungen_{title_slug}.csv"
|
||||||
|
output.headers["Content-Type"] = "text/csv; charset=utf-8"
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
@appoint_bp.route('/client/<appointment_id>', methods=['POST', 'GET'])
|
@appoint_bp.route('/client/<appointment_id>', methods=['POST', 'GET'])
|
||||||
def client(appointment_id):
|
def client(appointment_id):
|
||||||
"""
|
"""
|
||||||
The Route for the terminplaner to work with the client
|
The Route for the terminplaner to work with the client and allow owners to manage it.
|
||||||
"""
|
"""
|
||||||
guard = _require_module_enabled()
|
guard = _require_module_enabled()
|
||||||
if guard:
|
if guard:
|
||||||
@@ -50,6 +153,10 @@ def client(appointment_id):
|
|||||||
current_user = str(session.get('username', '') or '').strip()
|
current_user = str(session.get('username', '') or '').strip()
|
||||||
appointment_item = termin.get_item(appointment_id) or {}
|
appointment_item = termin.get_item(appointment_id) or {}
|
||||||
appointment_owner = str(appointment_item.get('user', '') or '').strip()
|
appointment_owner = str(appointment_item.get('user', '') or '').strip()
|
||||||
|
|
||||||
|
custom_fields = appointment_item.get('custom_fields', [])
|
||||||
|
|
||||||
|
# Permissions check
|
||||||
can_view_booking_names = False
|
can_view_booking_names = False
|
||||||
if current_user:
|
if current_user:
|
||||||
try:
|
try:
|
||||||
@@ -57,6 +164,7 @@ def client(appointment_id):
|
|||||||
except Exception:
|
except Exception:
|
||||||
can_view_booking_names = bool(current_user == appointment_owner)
|
can_view_booking_names = bool(current_user == appointment_owner)
|
||||||
|
|
||||||
|
# Sanitize data for public/client view
|
||||||
available_for_view = dict(available)
|
available_for_view = dict(available)
|
||||||
if not can_view_booking_names:
|
if not can_view_booking_names:
|
||||||
sanitized_bookings = []
|
sanitized_bookings = []
|
||||||
@@ -70,31 +178,58 @@ def client(appointment_id):
|
|||||||
available_for_view['slots_booked'] = sanitized_bookings
|
available_for_view['slots_booked'] = sanitized_bookings
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
start_daytime = request.form.get('start_day_time')
|
action = request.form.get('action', 'book')
|
||||||
username = request.form.get('client_name')
|
|
||||||
if not start_daytime or not username:
|
|
||||||
flash('Bitte Name und gewünschte Uhrzeit angeben.', 'error')
|
|
||||||
return render_template(
|
|
||||||
'termin_client.html',
|
|
||||||
appointment_id=appointment_id,
|
|
||||||
available=available_for_view,
|
|
||||||
current_user=session.get('username', ''),
|
|
||||||
tenant_id=_current_tenant_id(),
|
|
||||||
can_view_booking_names=can_view_booking_names,
|
|
||||||
)
|
|
||||||
|
|
||||||
if appointment_service.book_slot(appointment_id, start_daytime, username):
|
# Case 1: Admin/Owner cancels a booking
|
||||||
return redirect(
|
if action == 'delete' and can_view_booking_names:
|
||||||
url_for(
|
slot_time = request.form.get('slot_time')
|
||||||
'terminplaner.client_success',
|
client_name = request.form.get('target_client_name')
|
||||||
|
|
||||||
|
current_slots = appointment_item.get('slots_booked', []) or []
|
||||||
|
|
||||||
|
# Keep everything EXCEPT the item targeted for deletion
|
||||||
|
updated_slots = [
|
||||||
|
slot for slot in current_slots
|
||||||
|
if not (isinstance(slot, (list, tuple)) and slot[0] == slot_time and slot[1] == client_name)
|
||||||
|
]
|
||||||
|
|
||||||
|
if termin.update(appointment_id, updated_slots):
|
||||||
|
flash('Buchung wurde erfolgreich gelöscht.', 'success')
|
||||||
|
else:
|
||||||
|
flash('Fehler beim Löschen der Buchung.', 'error')
|
||||||
|
|
||||||
|
return redirect(url_for('terminplaner.client', appointment_id=appointment_id, tenant=_current_tenant_id() or None))
|
||||||
|
|
||||||
|
# Case 2: Client books a slot
|
||||||
|
elif action == 'book':
|
||||||
|
start_daytime = request.form.get('start_day_time')
|
||||||
|
username = request.form.get('client_name')
|
||||||
|
custom_answers = tuple(request.form.getlist('custom_answers')) # Cast to tuple for DB safety
|
||||||
|
|
||||||
|
if not start_daytime or not username:
|
||||||
|
flash('Bitte Name und gewünschte Uhrzeit angeben.', 'error')
|
||||||
|
return render_template(
|
||||||
|
'termin_client.html',
|
||||||
appointment_id=appointment_id,
|
appointment_id=appointment_id,
|
||||||
tenant=_current_tenant_id() or None,
|
available=available_for_view,
|
||||||
start=start_daytime,
|
current_user=session.get('username', ''),
|
||||||
name=username,
|
tenant_id=_current_tenant_id(),
|
||||||
|
can_view_booking_names=can_view_booking_names,
|
||||||
|
custom_fields=custom_fields
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
flash('Der Termin konnte nicht gespeichert werden.', 'error')
|
if appointment_service.book_slot(appointment_id, start_daytime, username, custom=custom_answers):
|
||||||
|
return redirect(
|
||||||
|
url_for(
|
||||||
|
'terminplaner.client_success',
|
||||||
|
appointment_id=appointment_id,
|
||||||
|
tenant=_current_tenant_id() or None,
|
||||||
|
start=start_daytime,
|
||||||
|
name=username,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
flash('Der Termin konnte nicht gespeichert werden. Eventuell ist der Slot bereits voll.', 'error')
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'termin_client.html',
|
'termin_client.html',
|
||||||
@@ -103,6 +238,8 @@ def client(appointment_id):
|
|||||||
current_user=session.get('username', ''),
|
current_user=session.get('username', ''),
|
||||||
tenant_id=_current_tenant_id(),
|
tenant_id=_current_tenant_id(),
|
||||||
can_view_booking_names=can_view_booking_names,
|
can_view_booking_names=can_view_booking_names,
|
||||||
|
custom_fields=custom_fields,
|
||||||
|
appointment_item=appointment_item
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -154,7 +291,7 @@ def delete_appointment(appointment_id):
|
|||||||
@appoint_bp.route('/configure', methods=['GET', 'POST'])
|
@appoint_bp.route('/configure', methods=['GET', 'POST'])
|
||||||
def configure():
|
def configure():
|
||||||
"""
|
"""
|
||||||
Route for authenticated persons to configure a new appointment for them
|
Route for authenticated persons to configure a new appointment schedule
|
||||||
"""
|
"""
|
||||||
guard = _require_module_enabled()
|
guard = _require_module_enabled()
|
||||||
if guard:
|
if guard:
|
||||||
@@ -169,13 +306,18 @@ def configure():
|
|||||||
end = request.form.get('end_date')
|
end = request.form.get('end_date')
|
||||||
time = request.form.get('time_frame')
|
time = request.form.get('time_frame')
|
||||||
slots_amount = request.form.get('slots_amounts')
|
slots_amount = request.form.get('slots_amounts')
|
||||||
slot_length = request.form.get('slot_length') # Korrigiert: slot_length
|
slot_length = request.form.get('slot_length')
|
||||||
mail = request.form.get('mail', '')
|
mail = request.form.get('mail', '')
|
||||||
note = request.form.get('note', '')
|
note = request.form.get('note', '')
|
||||||
add_to_calendar = request.form.get('add_to_calendar') == 'on'
|
add_to_calendar = request.form.get('add_to_calendar') == 'on'
|
||||||
title = request.form.get('title', '').strip() # Korrigiert: title statt titel
|
title = request.form.get('title', '').strip()
|
||||||
|
custom = request.form.getlist('custom_fields')
|
||||||
|
|
||||||
|
try:
|
||||||
|
clients_p_slot = int(request.form.get('clients_per_slot', 1))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
clients_p_slot = 1
|
||||||
|
|
||||||
# Abfrage ebenfalls auf title und slot_length angepasst
|
|
||||||
if not start or not end or not time or not slots_amount or not slot_length or not title:
|
if not start or not end or not time or not slots_amount or not slot_length or not title:
|
||||||
flash('Bitte alle Pflichtfelder ausfüllen.', 'error')
|
flash('Bitte alle Pflichtfelder ausfüllen.', 'error')
|
||||||
return render_template(
|
return render_template(
|
||||||
@@ -185,28 +327,46 @@ def configure():
|
|||||||
email_service_enabled=cfg.EMAIL_ENABLED,
|
email_service_enabled=cfg.EMAIL_ENABLED,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Variablen im Funktionsaufruf aktualisiert
|
# Call the database service function (standardized to match your underlying code)
|
||||||
result = appointment_service.new(start, end, time, slots_amount, slot_length, session["username"], mail, note, calendar_enabled=add_to_calendar, title=title)
|
inserted_id = appointment_service.new(
|
||||||
|
date_start=start,
|
||||||
|
date_end=end,
|
||||||
|
time_span=time,
|
||||||
|
slots=slots_amount,
|
||||||
|
slot_length=slot_length,
|
||||||
|
user=session["username"],
|
||||||
|
mail=mail,
|
||||||
|
note=note,
|
||||||
|
calendar_enabled=add_to_calendar,
|
||||||
|
title=title,
|
||||||
|
custom_fields=custom,
|
||||||
|
clients_per_slot=clients_p_slot
|
||||||
|
)
|
||||||
|
|
||||||
|
if not inserted_id:
|
||||||
|
flash('Fehler beim Erstellen des Terminplans.', 'error')
|
||||||
|
return redirect(url_for('terminplaner.configure'))
|
||||||
|
|
||||||
flash('Der Terminplan wurde angelegt.', 'success')
|
flash('Der Terminplan wurde angelegt.', 'success')
|
||||||
return render_template(
|
return render_template(
|
||||||
'termin_configure.html',
|
'termin_configure.html',
|
||||||
school_periods=cfg.SCHOOL_PERIODS,
|
school_periods=cfg.SCHOOL_PERIODS,
|
||||||
generated_link=result['link'],
|
generated_link=inserted_id['link'],
|
||||||
calendar_link=result.get('calendar_link'),
|
calendar_link=None,
|
||||||
add_to_calendar=add_to_calendar,
|
add_to_calendar=add_to_calendar,
|
||||||
email_service_enabled=cfg.EMAIL_ENABLED,
|
email_service_enabled=cfg.EMAIL_ENABLED,
|
||||||
title=title,
|
title=title,
|
||||||
)
|
)
|
||||||
elif request.method == "GET":
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'termin_configure.html',
|
'termin_configure.html',
|
||||||
school_periods=cfg.SCHOOL_PERIODS,
|
school_periods=cfg.SCHOOL_PERIODS,
|
||||||
generated_link=None,
|
generated_link=None,
|
||||||
calendar_link=None,
|
calendar_link=None,
|
||||||
add_to_calendar=False,
|
add_to_calendar=False,
|
||||||
email_service_enabled=cfg.EMAIL_ENABLED,
|
email_service_enabled=cfg.EMAIL_ENABLED,
|
||||||
title=None,
|
title=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@appoint_bp.route('/calendar/<appointment_id>.ics', methods=['GET'])
|
@appoint_bp.route('/calendar/<appointment_id>.ics', methods=['GET'])
|
||||||
@@ -244,6 +404,138 @@ def client_slot_calendar_export(appointment_id):
|
|||||||
response.headers['Content-Disposition'] = f'attachment; filename=termin-{title}-{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('/export_pdf_brief/<plan_id>', methods=['GET'])
|
||||||
|
def export_pdf_brief(plan_id):
|
||||||
|
# 1. Daten holen (Hier als Beispiel, passe dies auf deine Datenbank an)
|
||||||
|
# terminplan = Terminplan.query.get(plan_id)
|
||||||
|
|
||||||
|
school_info = _get_school_info_for_export()
|
||||||
|
|
||||||
|
school_name = school_info.get('name', 'Schulname')
|
||||||
|
address = school_info.get('address', 'Adresse')
|
||||||
|
postal_code = school_info.get('postal_code', 'PLZ')
|
||||||
|
city = school_info.get('city', 'Stadt')
|
||||||
|
school_number = school_info.get('school_number', '000000')
|
||||||
|
it_admin = school_info.get('it_admin', 'IT-Beauftragter/in')
|
||||||
|
tenant_id = _current_tenant_id()
|
||||||
|
|
||||||
|
try:
|
||||||
|
link = url_for('terminplaner.client', appointment_id=plan_id, tenant=tenant_id or None, _external=True)
|
||||||
|
except Exception:
|
||||||
|
host = _resolve_public_base_url()
|
||||||
|
link = host + "/terminplaner/client/" + plan_id
|
||||||
|
if tenant_id:
|
||||||
|
link += f"?tenant={tenant_id}"
|
||||||
|
|
||||||
|
schul_daten = {
|
||||||
|
"schulname": school_name,
|
||||||
|
"strasse": address,
|
||||||
|
"plz_ort": f"{postal_code} {city}",
|
||||||
|
"schulnummer": school_number,
|
||||||
|
"it_admin": it_admin
|
||||||
|
}
|
||||||
|
|
||||||
|
plan_daten = {
|
||||||
|
"titel": termin.get_item(plan_id).get('title', 'Terminplan'), # terminplan.title
|
||||||
|
"link": link, # terminplan.link
|
||||||
|
"notizen": termin.get_item(plan_id).get('note', '') # terminplan.note
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. QR-Code Bild im temporären Ordner erstellen
|
||||||
|
qr = qrcode.QRCode(version=1, box_size=10, border=0)
|
||||||
|
qr.add_data(plan_daten["link"])
|
||||||
|
qr.make(fit=True)
|
||||||
|
img = qr.make_image(fill_color="black", back_color="white")
|
||||||
|
|
||||||
|
fd, qr_path = tempfile.mkstemp(suffix=".png")
|
||||||
|
os.close(fd)
|
||||||
|
img.save(qr_path)
|
||||||
|
|
||||||
|
# 3. PDF im Speicher aufbauen (BytesIO)
|
||||||
|
pdf_buffer = io.BytesIO()
|
||||||
|
doc = SimpleDocTemplate(
|
||||||
|
pdf_buffer,
|
||||||
|
pagesize=A4,
|
||||||
|
rightMargin=2*cm,
|
||||||
|
leftMargin=2.5*cm,
|
||||||
|
topMargin=2.5*cm,
|
||||||
|
bottomMargin=2*cm
|
||||||
|
)
|
||||||
|
|
||||||
|
styles = getSampleStyleSheet()
|
||||||
|
styles.add(ParagraphStyle(name='Sender', fontSize=8, textColor=grey))
|
||||||
|
styles.add(ParagraphStyle(name='Address', fontSize=10, leading=14))
|
||||||
|
styles.add(ParagraphStyle(name='Date', fontSize=10, alignment=2))
|
||||||
|
styles.add(ParagraphStyle(name='Subject', fontSize=14, fontName='Helvetica-Bold', spaceAfter=16, textColor=HexColor('#0f4c5c')))
|
||||||
|
styles.add(ParagraphStyle(name='Body', fontSize=11, leading=16, spaceAfter=12))
|
||||||
|
styles.add(ParagraphStyle(name='Notes', fontSize=10, leading=14, textColor=HexColor("#444444")))
|
||||||
|
|
||||||
|
elements = []
|
||||||
|
|
||||||
|
# Absenderzeile
|
||||||
|
sender_text = f"<u>{schul_daten['schulname']} • {schul_daten['strasse']} • {schul_daten['plz_ort']}</u>"
|
||||||
|
elements.append(Paragraph(sender_text, styles['Sender']))
|
||||||
|
elements.append(Spacer(1, 1.5*cm))
|
||||||
|
|
||||||
|
# Sichtfenster-Adresse (Generisch)
|
||||||
|
elements.append(Paragraph("An die<br/>Teilnehmerinnen und Teilnehmer<br/>des Termins", styles['Address']))
|
||||||
|
elements.append(Spacer(1, 2*cm))
|
||||||
|
|
||||||
|
# Datum (Hier statisch zum Test, ggf. dynamisch per datetime)
|
||||||
|
import datetime
|
||||||
|
heute = datetime.datetime.now().strftime("%d.%m.%Y")
|
||||||
|
elements.append(Paragraph(f"{schul_daten['plz_ort']}, den {heute}", styles['Date']))
|
||||||
|
elements.append(Spacer(1, 1*cm))
|
||||||
|
|
||||||
|
# Betreff
|
||||||
|
elements.append(Paragraph(f"Einladung zur Terminbuchung: {plan_daten['titel']}", styles['Subject']))
|
||||||
|
|
||||||
|
# Text
|
||||||
|
elements.append(Paragraph("Sehr geehrte Damen und Herren,", styles['Body']))
|
||||||
|
elements.append(Paragraph("hiermit möchten wir Sie herzlich einladen, einen Termin für unsere anstehende Veranstaltung zu buchen. Um den Prozess für alle Beteiligten so einfach und effizient wie möglich zu gestalten, nutzen wir unser Online-Buchungssystem.", styles['Body']))
|
||||||
|
elements.append(Spacer(1, 0.5*cm))
|
||||||
|
|
||||||
|
# Link
|
||||||
|
elements.append(Paragraph("<b>Ihr persönlicher Buchungslink:</b>", styles['Body']))
|
||||||
|
link_html = f'<a href="{plan_daten["link"]}?" color="#16697a">{plan_daten["link"]}</a>'
|
||||||
|
elements.append(Paragraph(link_html, styles['Body']))
|
||||||
|
|
||||||
|
# Das vorhin erstellte QR-Code Bild einfügen
|
||||||
|
elements.append(Spacer(1, 0.2*cm))
|
||||||
|
elements.append(Image(qr_path, width=3*cm, height=3*cm, hAlign='LEFT'))
|
||||||
|
elements.append(Spacer(1, 0.5*cm))
|
||||||
|
|
||||||
|
# Notizen (falls vorhanden)
|
||||||
|
if plan_daten.get('notizen'):
|
||||||
|
elements.append(Paragraph("<b>Zusätzliche Informationen zum Termin:</b>", styles['Body']))
|
||||||
|
elements.append(Paragraph(plan_daten['notizen'], styles['Notes']))
|
||||||
|
|
||||||
|
elements.append(Spacer(1, 1.5*cm))
|
||||||
|
|
||||||
|
# Grußformel
|
||||||
|
elements.append(Paragraph("Mit freundlichen Grüßen,", styles['Body']))
|
||||||
|
elements.append(Spacer(1, 1.5*cm))
|
||||||
|
elements.append(Paragraph(f"<b>{schul_daten['schulname']}</b>", styles['Body']))
|
||||||
|
|
||||||
|
# PDF fertigstellen
|
||||||
|
doc.build(elements)
|
||||||
|
|
||||||
|
# Temporäres Bild löschen
|
||||||
|
if os.path.exists(qr_path):
|
||||||
|
os.remove(qr_path)
|
||||||
|
|
||||||
|
# Buffer auf Anfang zurücksetzen
|
||||||
|
pdf_buffer.seek(0)
|
||||||
|
|
||||||
|
# An Nutzer ausliefern
|
||||||
|
return send_file(
|
||||||
|
pdf_buffer,
|
||||||
|
mimetype='application/pdf',
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=f"Einladung_{plan_daten['titel'].replace(' ', '_')}.pdf"
|
||||||
|
)
|
||||||
|
|
||||||
@appoint_bp.route('/')
|
@appoint_bp.route('/')
|
||||||
def main():
|
def main():
|
||||||
guard = _require_module_enabled()
|
guard = _require_module_enabled()
|
||||||
@@ -254,6 +546,7 @@ def main():
|
|||||||
upcoming_events = appointment_service.get_user_upcoming_events(current_user) if current_user else []
|
upcoming_events = appointment_service.get_user_upcoming_events(current_user) if current_user else []
|
||||||
tenant_id = _current_tenant_id()
|
tenant_id = _current_tenant_id()
|
||||||
|
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'terminplaner.html',
|
'terminplaner.html',
|
||||||
school_periods=cfg.SCHOOL_PERIODS,
|
school_periods=cfg.SCHOOL_PERIODS,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+274
-119
@@ -39,13 +39,19 @@
|
|||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-12 col-xxl-11">
|
<div class="col-12 col-xxl-11">
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
|
|
||||||
<div class="col-12 col-lg-4">
|
<div class="col-12 col-lg-4">
|
||||||
<div class="card border-0 shadow-lg rounded-4 h-100">
|
<div class="card border-0 shadow-lg rounded-4 h-100">
|
||||||
<div class="card-body p-4 p-md-5">
|
<div class="card-body p-4 p-md-5">
|
||||||
<p class="text-uppercase text-muted fw-semibold mb-2">Terminplaner</p>
|
<p class="text-uppercase text-muted fw-semibold mb-2">Terminplaner</p>
|
||||||
<h1 class="h3 fw-bold mb-3">Termin buchen - {{ available.title }}</h1>
|
{% if can_view_booking_names %}
|
||||||
<p class="text-muted mb-4">Wählen Sie im Kalender einen freien Slot aus. Den gewählten Termin können Sie danach direkt wie in einem Kalender-Block verschieben.</p>
|
<h1 class="h3 fw-bold mb-3">Termin Übersicht - {{ available.title }}</h1>
|
||||||
|
<p class="text-muted mb-4">Hier können Sie alle Buchungen, die bereits eingegangen sind, einsehen und verwalten.</p>
|
||||||
|
{% else %}
|
||||||
|
<h1 class="h3 fw-bold mb-3">Termin buchen - {{ available.title }}</h1>
|
||||||
|
<p class="text-muted mb-4">Wählen Sie im Kalender einen freien Slot aus. Den gewählten Termin können Sie danach direkt wie in einem Kalender-Block verschieben.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="p-3 rounded-3 bg-light mb-3">
|
<div class="p-3 rounded-3 bg-light mb-3">
|
||||||
<div class="fw-semibold">Zeitraum</div>
|
<div class="fw-semibold">Zeitraum</div>
|
||||||
<div>{{ available.date_start }} bis {{ available.date_end }}</div>
|
<div>{{ available.date_start }} bis {{ available.date_end }}</div>
|
||||||
@@ -58,23 +64,24 @@
|
|||||||
<div class="fw-semibold">Slot-Länge</div>
|
<div class="fw-semibold">Slot-Länge</div>
|
||||||
<div>{{ available.slot_lenght }} Minuten</div>
|
<div>{{ available.slot_lenght }} Minuten</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if not can_view_booking_names %}
|
||||||
<div class="p-3 rounded-3 bg-light mb-3">
|
<div class="p-3 rounded-3 bg-light mb-3">
|
||||||
<div class="fw-semibold mb-2">Gewählter Termin</div>
|
<div class="fw-semibold mb-2">Gewählter Termin</div>
|
||||||
<div id="selected-slot-badge" class="text-muted small">Noch kein Slot ausgewählt.</div>
|
<div id="selected-slot-badge" class="text-muted small">Noch kein Slot ausgewählt.</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if available.slots_booked %}
|
{% if available.slots_booked %}
|
||||||
<div class="p-3 rounded-3 bg-light">
|
<div class="p-3 rounded-3 bg-light">
|
||||||
<div class="fw-semibold mb-2">Bereits gebucht</div>
|
<div class="fw-semibold mb-2">Bereits gebucht</div>
|
||||||
<ul class="mb-0 small">
|
<ul class="mb-0 small text-muted">
|
||||||
{% for booking in available.slots_booked %}
|
{% for booking in available.slots_booked %}
|
||||||
<li>
|
<li>
|
||||||
{{ booking.start }}
|
{% if booking is mapping %}
|
||||||
{% if can_view_booking_names and booking.name %}
|
{{ booking.get('start', '') }} - Belegt
|
||||||
- {{ booking.name }}
|
|
||||||
{% else %}
|
{% else %}
|
||||||
- Belegt
|
{{ booking[0] }} - {% if can_view_booking_names %}<span class="fw-semibold text-dark">{{ booking[1] }}</span>{% else %}Belegt{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -84,39 +91,161 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12 col-lg-8">
|
<div class="col-12 col-lg-8">
|
||||||
<div class="card border-0 shadow-lg rounded-4 h-100">
|
<div class="card border-0 shadow-lg rounded-4 h-100">
|
||||||
<div class="card-body p-4 p-md-5 bg-white">
|
<div class="card-body p-4 p-md-5 bg-white">
|
||||||
<h2 class="h4 fw-bold mb-3">Termin im Kalender auswählen</h2>
|
|
||||||
|
{% if can_view_booking_names %}
|
||||||
<div class="day-slider-wrap mb-3">
|
<!-- ADMIN / AUTOREN ANSICHT -->
|
||||||
<div class="d-flex justify-content-between align-items-center gap-2 mb-2">
|
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
|
||||||
<span class="small text-muted">Tag wählen</span>
|
<div>
|
||||||
<strong id="day-slider-label" class="small"></strong>
|
<h2 class="h4 fw-bold mb-0">Eingegangene Buchungen</h2>
|
||||||
|
<div class="text-muted small">Verwalten Sie die Termine Ihrer Klienten</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 align-self-start align-self-md-center">
|
||||||
|
<!-- Neuer CSV Export Button -->
|
||||||
|
<a href="{{ url_for('terminplaner.export_csv', appointment_id=appointment_id) }}" class="btn btn-outline-success rounded-3 d-inline-flex align-items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-download" viewBox="0 0 16 16">
|
||||||
|
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v2.5a.5.5 0 0 1 .5-.5"/>
|
||||||
|
<path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708z"/>
|
||||||
|
</svg>
|
||||||
|
Excel / CSV Export
|
||||||
|
</a>
|
||||||
|
<span class="badge bg-primary rounded-pill fs-6 px-3 d-flex align-items-center">
|
||||||
|
{{ available.slots_total - available.slots_left }} von {{ available.slots_total }} belegt
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input id="day-slider" type="range" class="form-range m-0" min="0" max="0" value="0">
|
<div class="bg-light p-3 rounded-3 mb-4">
|
||||||
</div>
|
<div class="row g-2 align-items-center">
|
||||||
|
<div class="col-12 col-md-8">
|
||||||
<div id="client-slot-calendar" class="mb-4"></div>
|
<label for="table-search" class="form-label small fw-semibold text-muted mb-1">Live-Filter (Name, Uhrzeit, Zusatzfelder...)</label>
|
||||||
|
<input type="text" id="table-search" class="form-control form-control-lg bg-white border" placeholder="Suchbegriff eingeben...">
|
||||||
<form id="client-booking-form" method="post" action="{{ url_for('terminplaner.client', appointment_id=appointment_id, tenant=tenant_id) }}" class="vstack gap-3">
|
</div>
|
||||||
<div>
|
<div class="col-12 col-md-4 d-flex align-items-end h-100 mt-md-4">
|
||||||
<label for="start_day_time" class="form-label fw-semibold">Gewünschter Zeitpunkt</label>
|
<button type="button" id="clear-filter-btn" class="btn btn-outline-secondary w-100 btn-lg">Filter zurücksetzen</button>
|
||||||
<input type="text" id="start_day_time" name="start_day_time" class="form-control form-control-lg" placeholder="Bitte im Kalender auswählen" readonly required>
|
</div>
|
||||||
<div class="form-text">Klicken Sie auf einen freien Slot oder verschieben Sie den gewählten Block.</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label for="client_name" class="form-label fw-semibold">Ihr Name</label>
|
{% if available.slots_booked %}
|
||||||
<input type="text" id="client_name" name="client_name" class="form-control form-control-lg" placeholder="Vor- und Nachname" required>
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle border-top" id="admin-booking-table">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="py-3">Zeitpunkt</th>
|
||||||
|
<th scope="col" class="py-3">Name</th>
|
||||||
|
<!-- Spaltenüberschriften für Custom Fields -->
|
||||||
|
{% for field_label in custom_fields %}
|
||||||
|
<th scope="col" class="py-3">{{ field_label }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
<th scope="col" class="py-3 text-end">Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="booking-table-body">
|
||||||
|
{# Wir loopen jetzt über die rohen DB-Daten statt über die beschnittene Service-Funktion #}
|
||||||
|
{% for booking in appointment_item.get('slots_booked', []) %}
|
||||||
|
<tr>
|
||||||
|
<!-- 1. ZEITPUNKT -->
|
||||||
|
<td class="fw-semibold text-primary py-3 text-nowrap">
|
||||||
|
{{ booking[0] }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- 2. CLIENT NAME -->
|
||||||
|
<td>
|
||||||
|
<span class="fw-bold text-dark">{{ booking[1] }}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- 3. CUSTOM FIELDS (Präziser Index-Abgleich gegen Spaltenkopf) -->
|
||||||
|
{% for field in custom_fields %}
|
||||||
|
<td>
|
||||||
|
{# Prüft ob für dieses Feld eine Antwort am exakt gleichen Index existiert #}
|
||||||
|
{% if booking|length > 2 and booking[2] and booking[2]|length > loop.index0 %}
|
||||||
|
{% set answer = booking[2][loop.index0] %}
|
||||||
|
{{ answer if answer else '<span class="text-muted small">-</span>'|safe }}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted small">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- 4. AKTION (LÖSCHEN) -->
|
||||||
|
<td class="text-end">
|
||||||
|
<form method="post" action="{{ url_for('terminplaner.client', appointment_id=appointment_id, tenant=tenant_id) }}" onsubmit="return confirm('Möchten Sie diesen Termin wirklich löschen?');" class="d-inline">
|
||||||
|
<input type="hidden" name="action" value="delete">
|
||||||
|
<input type="hidden" name="slot_time" value="{{ booking[0] }}">
|
||||||
|
<input type="hidden" name="target_client_name" value="{{ booking[1] }}">
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger px-3 rounded-3">
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-column flex-sm-row gap-2 pt-2">
|
|
||||||
<button type="submit" class="btn btn-primary btn-lg">Termin buchen</button>
|
<div id="no-results-msg" class="text-center py-4 text-muted d-none">
|
||||||
|
Keine Buchungen entsprechen Ihrem Filter.
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<p class="mb-0">Bisher sind noch keine Buchungen für diesen Terminplaner eingegangen.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="pt-4 border-top mt-4">
|
||||||
<a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main', tenant=tenant_id) }}">Zur Übersicht</a>
|
<a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main', tenant=tenant_id) }}">Zur Übersicht</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
|
{% else %}
|
||||||
|
<h2 class="h4 fw-bold mb-3">Termin im Kalender auswählen</h2>
|
||||||
|
|
||||||
|
<div class="day-slider-wrap mb-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center gap-2 mb-2">
|
||||||
|
<span class="small text-muted">Tag wählen</span>
|
||||||
|
<strong id="day-slider-label" class="small"></strong>
|
||||||
|
</div>
|
||||||
|
<input id="day-slider" type="range" class="form-range m-0" min="0" max="0" value="0">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="client-slot-calendar" class="mb-4"></div>
|
||||||
|
|
||||||
|
<form id="client-booking-form" method="post" action="{{ url_for('terminplaner.client', appointment_id=appointment_id, tenant=tenant_id) }}" class="vstack gap-3">
|
||||||
|
<div>
|
||||||
|
<label for="start_day_time" class="form-label fw-semibold">Gewünschter Zeitpunkt</label>
|
||||||
|
<input type="text" id="start_day_time" name="start_day_time" class="form-control form-control-lg" placeholder="Bitte im Kalender auswählen" readonly required>
|
||||||
|
<div class="form-text">Klicken Sie auf einen freien Slot oder verschieben Sie den gewählten Block.</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="client_name" class="form-label fw-semibold">Ihr Name</label>
|
||||||
|
<input type="text" id="client_name" name="client_name" class="form-control form-control-lg" placeholder="Vor- und Nachname" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if custom_fields %}
|
||||||
|
{% for field_label in custom_fields %}
|
||||||
|
<div>
|
||||||
|
<label class="form-label fw-semibold">{{ field_label }}</label>
|
||||||
|
<input type="text" name="custom_answers" class="form-control form-control-lg" placeholder="{{ field_label }} eingeben" required>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="d-flex flex-column flex-sm-row gap-2 pt-2">
|
||||||
|
<input type="hidden" name="action" value="book">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">Termin buchen</button>
|
||||||
|
<a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main', tenant=tenant_id) }}">Zur Übersicht</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,11 +271,58 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const searchInput = document.getElementById('table-search');
|
||||||
|
const clearBtn = document.getElementById('clear-filter-btn');
|
||||||
|
const tableBody = document.getElementById('booking-table-body');
|
||||||
|
const noResultsMsg = document.getElementById('no-results-msg');
|
||||||
|
|
||||||
|
if (searchInput && tableBody) {
|
||||||
|
const rows = tableBody.querySelectorAll('tr');
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', function () {
|
||||||
|
const query = this.value.toLowerCase().trim();
|
||||||
|
let visibleRowsCount = 0;
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
// Liest den Textinhalt aller Spalten der Zeile aus
|
||||||
|
const rowText = row.textContent.toLowerCase();
|
||||||
|
|
||||||
|
if (rowText.includes(query)) {
|
||||||
|
row.classList.remove('d-none');
|
||||||
|
visibleRowsCount++;
|
||||||
|
} else {
|
||||||
|
row.classList.add('d-none');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Falls kein Filterergebnis gefunden wurde, Nachricht einblenden
|
||||||
|
if (visibleRowsCount === 0 && query !== '') {
|
||||||
|
noResultsMsg.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
noResultsMsg.classList.add('d-none');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter zurücksetzen Button
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.addEventListener('click', function () {
|
||||||
|
searchInput.value = '';
|
||||||
|
rows.forEach(row => row.classList.remove('d-none'));
|
||||||
|
noResultsMsg.classList.add('d-none');
|
||||||
|
searchInput.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const available = {{ available|tojson }};
|
const available = {{ available|tojson }};
|
||||||
const appointmentId = {{ appointment_id|tojson }};
|
const appointmentId = {{ appointment_id|tojson }};
|
||||||
|
const canViewBookingNames = {{ can_view_booking_names|tojson }}; // Injected from Flask
|
||||||
const slider = document.getElementById('day-slider');
|
const slider = document.getElementById('day-slider');
|
||||||
const sliderLabel = document.getElementById('day-slider-label');
|
const sliderLabel = document.getElementById('day-slider-label');
|
||||||
const sliderWrap = slider ? slider.closest('.day-slider-wrap') : null;
|
const sliderWrap = slider ? slider.closest('.day-slider-wrap') : null;
|
||||||
@@ -160,10 +336,10 @@
|
|||||||
const confirmBookingWithIcsBtn = document.getElementById('confirm-booking-with-ics');
|
const confirmBookingWithIcsBtn = document.getElementById('confirm-booking-with-ics');
|
||||||
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
|
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
|
||||||
|
|
||||||
const slotLength = Number.parseInt(available.slot_lenght, 10) || 45;
|
// Fallback for both spellings of slot length
|
||||||
const bookedStarts = new Set((available.slots_booked || []).map(function (entry) {
|
const slotLength = Number.parseInt(available.slot_length || available.slot_lenght, 10) || 45;
|
||||||
return String(entry.start || '').trim();
|
const clientsPerSlot = Number.parseInt(available.clients_per_slot, 10) || 1;
|
||||||
}).filter(Boolean));
|
const bookingsByTime = available.bookings_by_time || {};
|
||||||
|
|
||||||
function formatDateForInput(dateObj) {
|
function formatDateForInput(dateObj) {
|
||||||
const y = dateObj.getFullYear();
|
const y = dateObj.getFullYear();
|
||||||
@@ -227,6 +403,8 @@
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fullyBookedSlots = [];
|
||||||
|
|
||||||
function buildCandidateSlots() {
|
function buildCandidateSlots() {
|
||||||
const slots = [];
|
const slots = [];
|
||||||
const allowedDates = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
|
const allowedDates = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
|
||||||
@@ -234,9 +412,7 @@
|
|||||||
|
|
||||||
spans.forEach(function (entry) {
|
spans.forEach(function (entry) {
|
||||||
const parsed = parseTimeSpanEntry(entry);
|
const parsed = parseTimeSpanEntry(entry);
|
||||||
if (!parsed) {
|
if (!parsed) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetDates = parsed.date ? [parsed.date] : allowedDates;
|
const targetDates = parsed.date ? [parsed.date] : allowedDates;
|
||||||
targetDates.forEach(function (date) {
|
targetDates.forEach(function (date) {
|
||||||
@@ -249,10 +425,19 @@
|
|||||||
let cursor = new Date(from);
|
let cursor = new Date(from);
|
||||||
while (addMinutes(cursor, slotLength) <= to) {
|
while (addMinutes(cursor, slotLength) <= to) {
|
||||||
const slotStart = formatDateForInput(cursor);
|
const slotStart = formatDateForInput(cursor);
|
||||||
if (!bookedStarts.has(slotStart)) {
|
const currentBookingsCount = bookingsByTime[slotStart] || 0;
|
||||||
|
|
||||||
|
// Check if the current individual timestamp still has capacity left
|
||||||
|
if (currentBookingsCount < clientsPerSlot) {
|
||||||
slots.push({
|
slots.push({
|
||||||
start: slotStart,
|
start: slotStart,
|
||||||
end: formatDateForInput(addMinutes(cursor, slotLength)),
|
end: formatDateForInput(addMinutes(cursor, slotLength)),
|
||||||
|
bookingsCount: currentBookingsCount
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fullyBookedSlots.push({
|
||||||
|
start: slotStart,
|
||||||
|
end: formatDateForInput(addMinutes(cursor, slotLength))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
cursor = addMinutes(cursor, slotLength);
|
cursor = addMinutes(cursor, slotLength);
|
||||||
@@ -267,27 +452,8 @@
|
|||||||
const slotStartSet = new Set(candidateSlots.map(function (slot) { return slot.start; }));
|
const slotStartSet = new Set(candidateSlots.map(function (slot) { return slot.start; }));
|
||||||
const allDays = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
|
const allDays = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
|
||||||
|
|
||||||
// Show the requested UI time window from 08:15 to 20:00 and highlight available slots
|
let slotMinTime = '08:00:00';
|
||||||
let slotMinTime = '08:15:00';
|
|
||||||
let slotMaxTime = '20:00:00';
|
let slotMaxTime = '20:00:00';
|
||||||
if (candidateSlots.length > 0) {
|
|
||||||
const starts = candidateSlots.map(function (slot) {
|
|
||||||
return new Date(slot.start.replace(' ', 'T') + ':00');
|
|
||||||
}).filter(function (d) { return !Number.isNaN(d.getTime()); });
|
|
||||||
const ends = candidateSlots.map(function (slot) {
|
|
||||||
return new Date(slot.end.replace(' ', 'T') + ':00');
|
|
||||||
}).filter(function (d) { return !Number.isNaN(d.getTime()); });
|
|
||||||
|
|
||||||
// Keep the computed min/max for gap calculations below
|
|
||||||
var computedMinStart = null;
|
|
||||||
var computedMaxEnd = null;
|
|
||||||
if (starts.length > 0 && ends.length > 0) {
|
|
||||||
computedMinStart = starts[0];
|
|
||||||
computedMaxEnd = ends[0];
|
|
||||||
starts.forEach(function (d) { if (d < computedMinStart) computedMinStart = d; });
|
|
||||||
ends.forEach(function (d) { if (d > computedMaxEnd) computedMaxEnd = d; });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleStart = allDays[0] || String(available.date_start || '');
|
const visibleStart = allDays[0] || String(available.date_start || '');
|
||||||
const visibleEndExclusive = allDays.length > 0
|
const visibleEndExclusive = allDays.length > 0
|
||||||
@@ -322,14 +488,8 @@
|
|||||||
slotMinTime: slotMinTime,
|
slotMinTime: slotMinTime,
|
||||||
slotMaxTime: slotMaxTime,
|
slotMaxTime: slotMaxTime,
|
||||||
nowIndicator: true,
|
nowIndicator: true,
|
||||||
validRange: {
|
validRange: { start: visibleStart, end: visibleEndExclusive },
|
||||||
start: visibleStart,
|
visibleRange: { start: visibleStart, end: visibleEndExclusive },
|
||||||
end: visibleEndExclusive,
|
|
||||||
},
|
|
||||||
visibleRange: {
|
|
||||||
start: visibleStart,
|
|
||||||
end: visibleEndExclusive,
|
|
||||||
},
|
|
||||||
headerToolbar: {
|
headerToolbar: {
|
||||||
left: '',
|
left: '',
|
||||||
center: 'title',
|
center: 'title',
|
||||||
@@ -337,28 +497,22 @@
|
|||||||
},
|
},
|
||||||
events: [],
|
events: [],
|
||||||
eventDrop: function (info) {
|
eventDrop: function (info) {
|
||||||
if (info.event.id !== 'selected-slot') {
|
if (info.event.id !== 'selected-slot') return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const droppedStart = formatDateForInput(info.event.start);
|
const droppedStart = formatDateForInput(info.event.start);
|
||||||
if (!slotStartSet.has(droppedStart)) {
|
if (!slotStartSet.has(droppedStart)) {
|
||||||
info.revert();
|
info.revert();
|
||||||
window.alert('Dieser Zeitpunkt ist nicht als freier Slot verfügbar.');
|
window.alert('Dieser Zeitpunkt ist ausgebucht oder nicht verfügbar.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
applySelectedSlot(droppedStart);
|
applySelectedSlot(droppedStart);
|
||||||
},
|
},
|
||||||
eventClick: function (info) {
|
eventClick: function (info) {
|
||||||
const slotType = info.event.extendedProps ? info.event.extendedProps.slotType : '';
|
const slotType = info.event.extendedProps ? info.event.extendedProps.slotType : '';
|
||||||
if (slotType !== 'free') {
|
if (slotType !== 'free') return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
applySelectedSlot(info.event.extendedProps.slotStart || '');
|
applySelectedSlot(info.event.extendedProps.slotStart || '');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// No background greying: show full calendar skeleton and only mark possible slots
|
|
||||||
|
|
||||||
function updateSliderLabel() {
|
function updateSliderLabel() {
|
||||||
const dateList = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
|
const dateList = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
|
||||||
const idx = Number.parseInt(slider.value, 10) || 0;
|
const idx = Number.parseInt(slider.value, 10) || 0;
|
||||||
@@ -377,9 +531,7 @@
|
|||||||
selectedEvent = null;
|
selectedEvent = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedSlot) {
|
if (!selectedSlot) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = new Date(selectedSlot.replace(' ', 'T') + ':00');
|
const start = new Date(selectedSlot.replace(' ', 'T') + ':00');
|
||||||
const end = addMinutes(start, slotLength);
|
const end = addMinutes(start, slotLength);
|
||||||
@@ -398,13 +550,9 @@
|
|||||||
function getIcsDownloadUrl() {
|
function getIcsDownloadUrl() {
|
||||||
const base = {{ url_for('terminplaner.client_slot_calendar_export', appointment_id=appointment_id, tenant=tenant_id)|tojson }};
|
const base = {{ url_for('terminplaner.client_slot_calendar_export', appointment_id=appointment_id, tenant=tenant_id)|tojson }};
|
||||||
const url = new URL(base, window.location.origin);
|
const url = new URL(base, window.location.origin);
|
||||||
if (selectedSlot) {
|
if (selectedSlot) url.searchParams.set('start', selectedSlot);
|
||||||
url.searchParams.set('start', selectedSlot);
|
|
||||||
}
|
|
||||||
const name = String(clientNameInput.value || '').trim();
|
const name = String(clientNameInput.value || '').trim();
|
||||||
if (name) {
|
if (name) url.searchParams.set('name', name);
|
||||||
url.searchParams.set('name', name);
|
|
||||||
}
|
|
||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,14 +563,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No background gaps: keep full skeleton/grid visible
|
// Render Active/Available Slots with Live Seat Counts
|
||||||
|
|
||||||
// Add candidate free slots (blue)
|
|
||||||
candidateSlots.forEach(function (slot) {
|
candidateSlots.forEach(function (slot) {
|
||||||
const start = new Date(slot.start.replace(' ', 'T') + ':00');
|
const start = new Date(slot.start.replace(' ', 'T') + ':00');
|
||||||
const end = new Date(slot.end.replace(' ', 'T') + ':00');
|
const end = new Date(slot.end.replace(' ', 'T') + ':00');
|
||||||
|
|
||||||
|
let displayTitle = 'Freier Slot';
|
||||||
|
if (clientsPerSlot > 1) {
|
||||||
|
const remainingSeats = clientsPerSlot - slot.bookingsCount;
|
||||||
|
displayTitle += ' (' + remainingSeats + '/' + clientsPerSlot + ' frei)';
|
||||||
|
}
|
||||||
|
|
||||||
calendar.addEvent({
|
calendar.addEvent({
|
||||||
title: 'Freier Slot',
|
title: displayTitle,
|
||||||
start: start,
|
start: start,
|
||||||
end: end,
|
end: end,
|
||||||
color: '#0d6efd',
|
color: '#0d6efd',
|
||||||
@@ -435,46 +588,54 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
(available.slots_booked || []).forEach(function (booking) {
|
// Client View Optimization: Render solid red events ONLY if a slot is 100% full
|
||||||
const startStr = String(booking.start || '').trim();
|
fullyBookedSlots.forEach(function (slot) {
|
||||||
if (!startStr) {
|
const start = new Date(slot.start.replace(' ', 'T') + ':00');
|
||||||
return;
|
const end = new Date(slot.end.replace(' ', 'T') + ':00');
|
||||||
}
|
|
||||||
const start = new Date(startStr.replace(' ', 'T') + ':00');
|
|
||||||
const end = addMinutes(start, slotLength);
|
|
||||||
calendar.addEvent({
|
calendar.addEvent({
|
||||||
title: 'Gebucht' + (booking.name ? ' - ' + booking.name : ''),
|
title: 'Ausgebucht',
|
||||||
start: start,
|
start: start,
|
||||||
end: end,
|
end: end,
|
||||||
color: '#dc3545',
|
color: '#dc3545',
|
||||||
editable: false,
|
editable: false,
|
||||||
display: 'block',
|
display: 'block'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Admin Management View: Render detailed name blocks exclusively for management interaction
|
||||||
|
if (canViewBookingNames) {
|
||||||
|
(available.slots_booked || []).forEach(function (booking) {
|
||||||
|
const startStr = String(booking.start || '').trim();
|
||||||
|
if (!startStr) return;
|
||||||
|
const start = new Date(startStr.replace(' ', 'T') + ':00');
|
||||||
|
const end = addMinutes(start, slotLength);
|
||||||
|
calendar.addEvent({
|
||||||
|
title: 'Belegt: ' + (booking.name || 'Anonym'),
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
color: '#9e9e9e',
|
||||||
|
editable: false,
|
||||||
|
display: 'block',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event Listeners and Setup
|
||||||
form.addEventListener('submit', function (ev) {
|
form.addEventListener('submit', function (ev) {
|
||||||
if (!selectedSlotInput.value) {
|
if (!selectedSlotInput.value) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
window.alert('Bitte zuerst im Kalender einen freien Slot auswählen.');
|
window.alert('Bitte zuerst im Kalender einen freien Slot auswählen.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (allowImmediateSubmit) return;
|
||||||
if (allowImmediateSubmit) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
if (modal) {
|
if (modal) modal.show();
|
||||||
modal.show();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (confirmBookingOnlyBtn) {
|
if (confirmBookingOnlyBtn) {
|
||||||
confirmBookingOnlyBtn.addEventListener('click', function () {
|
confirmBookingOnlyBtn.addEventListener('click', function () {
|
||||||
allowImmediateSubmit = true;
|
allowImmediateSubmit = true;
|
||||||
if (modal) {
|
if (modal) modal.hide();
|
||||||
modal.hide();
|
|
||||||
}
|
|
||||||
form.submit();
|
form.submit();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -482,13 +643,9 @@
|
|||||||
if (confirmBookingWithIcsBtn) {
|
if (confirmBookingWithIcsBtn) {
|
||||||
confirmBookingWithIcsBtn.addEventListener('click', function () {
|
confirmBookingWithIcsBtn.addEventListener('click', function () {
|
||||||
const icsUrl = confirmBookingWithIcsBtn.getAttribute('data-ics-url') || getIcsDownloadUrl();
|
const icsUrl = confirmBookingWithIcsBtn.getAttribute('data-ics-url') || getIcsDownloadUrl();
|
||||||
if (icsUrl) {
|
if (icsUrl) window.open(icsUrl, '_blank');
|
||||||
window.open(icsUrl, '_blank');
|
|
||||||
}
|
|
||||||
allowImmediateSubmit = true;
|
allowImmediateSubmit = true;
|
||||||
if (modal) {
|
if (modal) modal.hide();
|
||||||
modal.hide();
|
|
||||||
}
|
|
||||||
form.submit();
|
form.submit();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -509,10 +666,8 @@
|
|||||||
slider.addEventListener('input', function () {
|
slider.addEventListener('input', function () {
|
||||||
const idx = Number.parseInt(slider.value, 10) || 0;
|
const idx = Number.parseInt(slider.value, 10) || 0;
|
||||||
updateSliderLabel();
|
updateSliderLabel();
|
||||||
if (allDays[idx]) {
|
if (allDays[idx] && calendar.view.type === 'timeGridDay') {
|
||||||
if (calendar.view.type === 'timeGridDay') {
|
calendar.gotoDate(allDays[idx]);
|
||||||
calendar.gotoDate(allDays[idx]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,10 @@
|
|||||||
<label for="slot_length" class="form-label fw-semibold">Slot-Länge in Minuten</label>
|
<label for="slot_length" class="form-label fw-semibold">Slot-Länge in Minuten</label>
|
||||||
<input type="number" id="slot_length" name="slot_length" class="form-control form-control-lg" min="1" value="45">
|
<input type="number" id="slot_length" name="slot_length" class="form-control form-control-lg" min="1" value="45">
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label fw-semibold text-primary">Nutzer pro Slot</label>
|
||||||
|
<input type="number" id="clients_per_slot" name="clients_per_slot" value="1">
|
||||||
|
</div>
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<label class="form-label fw-semibold text-primary">Automatisch berechnete Slots</label>
|
<label class="form-label fw-semibold text-primary">Automatisch berechnete Slots</label>
|
||||||
<div id="slots_amounts_display" class="form-control form-control-lg bg-primary-subtle text-primary fw-bold d-flex align-items-center">0</div>
|
<div id="slots_amounts_display" class="form-control form-control-lg bg-primary-subtle text-primary fw-bold d-flex align-items-center">0</div>
|
||||||
@@ -77,6 +81,16 @@
|
|||||||
<div class="form-text">Wird aus der gebuchten Zeit (abzüglich Pausen) und der Slot-Länge berechnet.</div>
|
<div class="form-text">Wird aus der gebuchten Zeit (abzüglich Pausen) und der Slot-Länge berechnet.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="custom-fields-container" class="mt-4">
|
||||||
|
<h3 class="mb-3">Benutzerdefinierte Felder</h3>
|
||||||
|
|
||||||
|
<div class="mb-3 custom-field-row">
|
||||||
|
<input type="text"
|
||||||
|
name="custom_fields"
|
||||||
|
class="form-control form-control-lg custom-dynamic-input"
|
||||||
|
placeholder="Zusatzfeld hinzufügen (z.B. Firma, Kundennummer...)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if mail_service_enabled %}
|
{% if mail_service_enabled %}
|
||||||
<div>
|
<div>
|
||||||
@@ -139,8 +153,9 @@
|
|||||||
const defaultPauseEndInput = document.getElementById('default_pause_end');
|
const defaultPauseEndInput = document.getElementById('default_pause_end');
|
||||||
|
|
||||||
const slotLengthInput = document.getElementById('slot_length');
|
const slotLengthInput = document.getElementById('slot_length');
|
||||||
|
const clientsperslot = document.getElementById('clients_per_slot')
|
||||||
const slotsAmountsInput = document.getElementById('slots_amounts');
|
const slotsAmountsInput = document.getElementById('slots_amounts');
|
||||||
const slotsAmountsDisplay = document.getElementById('slots_amounts_display'); // Neu: Anzeige-Element
|
const slotsAmountsDisplay = document.getElementById('slots_amounts_display');
|
||||||
|
|
||||||
if (!startDateInput || !endDateInput || !buildButton || !daysContainer || !timeFrameTextarea) {
|
if (!startDateInput || !endDateInput || !buildButton || !daysContainer || !timeFrameTextarea) {
|
||||||
return;
|
return;
|
||||||
@@ -188,16 +203,31 @@
|
|||||||
if (slotsAmountsDisplay) slotsAmountsDisplay.innerText = totalSlots + " Slots gesamt";
|
if (slotsAmountsDisplay) slotsAmountsDisplay.innerText = totalSlots + " Slots gesamt";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper utility in case your timeToMinutes handler doesn't catch empty strings safely
|
||||||
|
function safeTimeToMinutes(timeString) {
|
||||||
|
if (!timeString || typeof timeString !== 'string' || !timeString.includes(':')) {
|
||||||
|
return 0; // Return 0 instead of NaN for empty or missing values
|
||||||
|
}
|
||||||
|
const parts = timeString.split(':');
|
||||||
|
const hours = parseInt(parts[0], 10);
|
||||||
|
const minutes = parseInt(parts[1], 10);
|
||||||
|
if (isNaN(hours) || isNaN(minutes)) return 0;
|
||||||
|
return (hours * 60) + minutes;
|
||||||
|
}
|
||||||
|
|
||||||
// Berechnet die Slots basierend auf den konfigurierten Zeiten & Pausen
|
// Berechnet die Slots basierend auf den konfigurierten Zeiten & Pausen
|
||||||
function calculateSlots() {
|
function calculateSlots() {
|
||||||
if (!slotLengthInput || !slotsAmountsInput) return;
|
if (!slotLengthInput || !slotsAmountsInput || !clientsperslot) return;
|
||||||
|
|
||||||
const slotLength = parseInt(slotLengthInput.value, 10);
|
const slotLength = parseInt(slotLengthInput.value, 10);
|
||||||
if (isNaN(slotLength) || slotLength <= 0) {
|
if (isNaN(slotLength) || slotLength <= 0) {
|
||||||
updateSlotsDisplay(0);
|
updateSlotsDisplay(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIX 1: Make sure we parse the input VALUE, defaulting to 1 if empty or invalid
|
||||||
|
const multiplier = parseInt(clientsperslot.value, 10) || 1;
|
||||||
|
|
||||||
let totalSlots = 0;
|
let totalSlots = 0;
|
||||||
const rows = Array.from(daysContainer.querySelectorAll('[data-day-row]'));
|
const rows = Array.from(daysContainer.querySelectorAll('[data-day-row]'));
|
||||||
|
|
||||||
@@ -209,19 +239,20 @@
|
|||||||
|
|
||||||
if (!startTime || !endTime) return;
|
if (!startTime || !endTime) return;
|
||||||
|
|
||||||
const startMins = timeToMinutes(startTime);
|
// Using safety parsing to prevent missing values from generating NaN
|
||||||
const endMins = timeToMinutes(endTime);
|
const startMins = safeTimeToMinutes(startTime);
|
||||||
const pauseStartMins = timeToMinutes(pauseStart);
|
const endMins = safeTimeToMinutes(endTime);
|
||||||
const pauseEndMins = timeToMinutes(pauseEnd);
|
const pauseStartMins = safeTimeToMinutes(pauseStart);
|
||||||
|
const pauseEndMins = safeTimeToMinutes(pauseEnd);
|
||||||
|
|
||||||
if (endMins <= startMins) return; // Ungültige Zeit
|
if (isNaN(startMins) || isNaN(endMins) || endMins <= startMins) return;
|
||||||
|
|
||||||
// Wenn eine gültige Pause innerhalb der Start/Endzeit existiert
|
// Wenn eine gültige Pause innerhalb der Start/Endzeit existiert
|
||||||
if (pauseStartMins > 0 && pauseEndMins > 0 && pauseStartMins < pauseEndMins && pauseStartMins > startMins && pauseStartMins < endMins) {
|
if (pauseStartMins > 0 && pauseEndMins > 0 && pauseStartMins < pauseEndMins && pauseStartMins > startMins && pauseStartMins < endMins) {
|
||||||
// Segment 1 (Vor der Pause)
|
// Segment 1 (Vor der Pause)
|
||||||
const segment1 = pauseStartMins - startMins;
|
const segment1 = pauseStartMins - startMins;
|
||||||
totalSlots += Math.floor(segment1 / slotLength);
|
totalSlots += Math.floor(segment1 / slotLength);
|
||||||
|
|
||||||
// Segment 2 (Nach der Pause)
|
// Segment 2 (Nach der Pause)
|
||||||
const effectivePauseEnd = Math.min(pauseEndMins, endMins);
|
const effectivePauseEnd = Math.min(pauseEndMins, endMins);
|
||||||
const segment2 = endMins - effectivePauseEnd;
|
const segment2 = endMins - effectivePauseEnd;
|
||||||
@@ -234,7 +265,11 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
updateSlotsDisplay(totalSlots);
|
// FIX 2: Multiply by our safely parsed input value scalar
|
||||||
|
const totalSlotscomplete = totalSlots * multiplier;
|
||||||
|
|
||||||
|
// Safety check: if anything went wrong anyway, fallback to 0 instead of displaying NaN
|
||||||
|
updateSlotsDisplay(isNaN(totalSlotscomplete) ? 0 : totalSlotscomplete);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Synchronisiert das Textfeld und fügt den Break-Cut hinzu
|
// Synchronisiert das Textfeld und fügt den Break-Cut hinzu
|
||||||
@@ -351,6 +386,39 @@
|
|||||||
syncTextarea();
|
syncTextarea();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const container = document.getElementById('custom-fields-container');
|
||||||
|
|
||||||
|
// Monitor inputs inside the container using event delegation
|
||||||
|
container.addEventListener('input', function (event) {
|
||||||
|
// Only trigger if the user is typing inside our dynamic inputs
|
||||||
|
if (event.target.classList.contains('custom-dynamic-input')) {
|
||||||
|
const allInputs = container.querySelectorAll('.custom-dynamic-input');
|
||||||
|
const lastInput = allInputs[allInputs.length - 1];
|
||||||
|
|
||||||
|
// If the user typed something into the absolute last input field, spawn a new one
|
||||||
|
if (lastInput.value.trim() !== '') {
|
||||||
|
createNewFieldRow(container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to generate and append a clean new input row
|
||||||
|
function createNewFieldRow(targetContainer) {
|
||||||
|
const rowDiv = document.createElement('div');
|
||||||
|
rowDiv.className = 'mb-3 custom-field-row';
|
||||||
|
|
||||||
|
rowDiv.innerHTML = `
|
||||||
|
<input type="text"
|
||||||
|
name="custom_fields"
|
||||||
|
class="form-control form-control-lg custom-dynamic-input"
|
||||||
|
placeholder="Nächstes Zusatzfeld...">
|
||||||
|
`;
|
||||||
|
|
||||||
|
targetContainer.appendChild(rowDiv);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Event Listeners Registration
|
// Event Listeners Registration
|
||||||
buildButton.addEventListener('click', renderRows);
|
buildButton.addEventListener('click', renderRows);
|
||||||
startDateInput.addEventListener('change', renderRows);
|
startDateInput.addEventListener('change', renderRows);
|
||||||
@@ -366,10 +434,27 @@
|
|||||||
slotLengthInput.addEventListener('input', calculateSlots);
|
slotLengthInput.addEventListener('input', calculateSlots);
|
||||||
slotLengthInput.addEventListener('change', calculateSlots);
|
slotLengthInput.addEventListener('change', calculateSlots);
|
||||||
}
|
}
|
||||||
|
if (clientsperslot) {
|
||||||
|
clientsperslot.addEventListener('input', calculateSlots);
|
||||||
|
clientsperslot.addEventListener('change', calculateSlots);
|
||||||
|
}
|
||||||
|
|
||||||
if (startDateInput.value && endDateInput.value) {
|
if (startDateInput.value && endDateInput.value) {
|
||||||
renderRows();
|
renderRows();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const configForm = document.querySelector('form');
|
||||||
|
if (configForm) {
|
||||||
|
configForm.addEventListener('keydown', function(event) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
if (event.target.tagName === 'TEXTAREA') return;
|
||||||
|
if (event.target.tagName === 'BUTTON' && event.target.type === 'submit') return;
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
<div class="container py-4">
|
<div class="container py-4">
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-12 col-lg-11 col-xl-10">
|
<div class="col-12 col-lg-11 col-xl-10">
|
||||||
|
|
||||||
|
<!-- Hero Sektion -->
|
||||||
<section class="p-4 p-md-5 rounded-4 shadow-lg" style="background: linear-gradient(135deg, rgba(15,76,92,0.96), rgba(22,105,122,0.92)); color: #fff;">
|
<section class="p-4 p-md-5 rounded-4 shadow-lg" style="background: linear-gradient(135deg, rgba(15,76,92,0.96), rgba(22,105,122,0.92)); color: #fff;">
|
||||||
<div class="d-flex flex-column flex-lg-row justify-content-between gap-4 align-items-start align-items-lg-end">
|
<div class="d-flex flex-column flex-lg-row justify-content-between gap-4 align-items-start align-items-lg-end">
|
||||||
<div>
|
<div>
|
||||||
@@ -20,6 +22,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- NEU EINGEBAUT: Erfolgsmeldung für gerade erstellte Buchungslinks -->
|
||||||
|
{% if generated_link %}
|
||||||
|
<div class="alert alert-success mt-4 shadow-sm rounded-4">
|
||||||
|
<div class="fw-bold mb-1">Buchungslink erstellt</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
{% if mail_service_enabled %}
|
||||||
|
Teilen Sie diesen Link mit den Teilnehmenden oder versenden Sie ihn direkt per E-Mail.
|
||||||
|
{% else %}
|
||||||
|
Der E-Mail-Service ist deaktiviert. Teilen Sie diesen Link manuell mit den Teilnehmenden.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<a href="{{ generated_link }}" class="d-inline-block text-break mb-3">{{ generated_link }}</a>
|
||||||
|
|
||||||
|
<!-- PDF Export per ReportLab -->
|
||||||
|
<div class="pt-3 border-top border-success-subtle">
|
||||||
|
<div class="fw-semibold mb-1">Einladungsbrief (inkl. QR-Code)</div>
|
||||||
|
<p class="small text-muted mb-2">Laden Sie einen automatisch generierten Brief herunter, der den Buchungslink und einen passenden QR-Code enthält.</p>
|
||||||
|
<a href="{{ url_for('terminplaner.export_pdf_brief', plan_id=plan_id) }}" class="btn btn-outline-success btn-sm">
|
||||||
|
📄 Brief als PDF herunterladen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if calendar_link %}
|
||||||
|
<div class="mt-3 pt-3 border-top border-success-subtle">
|
||||||
|
<div class="fw-semibold mb-1">Kalendereintrag</div>
|
||||||
|
<a href="{{ calendar_link }}" class="btn btn-outline-primary btn-sm">.ics herunterladen</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<!-- ENDE NEUER BLOCK -->
|
||||||
|
|
||||||
|
<!-- Info-Karten -->
|
||||||
<div class="row g-4 mt-1">
|
<div class="row g-4 mt-1">
|
||||||
<div class="col-12 col-md-4">
|
<div class="col-12 col-md-4">
|
||||||
<div class="card h-100 shadow-sm border-0 rounded-4">
|
<div class="card h-100 shadow-sm border-0 rounded-4">
|
||||||
@@ -59,6 +94,7 @@
|
|||||||
<p class="mb-0 text-muted">Sie können Termine anlegen, den Kalender prüfen und Buchungslinks verteilen.</p>
|
<p class="mb-0 text-muted">Sie können Termine anlegen, den Kalender prüfen und Buchungslinks verteilen.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- "Tabelle" (Liste) der kommenden Termine -->
|
||||||
<div class="mt-4 p-4 rounded-4 bg-white shadow-sm">
|
<div class="mt-4 p-4 rounded-4 bg-white shadow-sm">
|
||||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-3">
|
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-3">
|
||||||
<h2 class="h5 fw-bold mb-0">Kommende Termine</h2>
|
<h2 class="h5 fw-bold mb-0">Kommende Termine</h2>
|
||||||
@@ -84,10 +120,18 @@
|
|||||||
<div class="text-lg-end">
|
<div class="text-lg-end">
|
||||||
<div class="small mb-2">Gebucht: <strong>{{ event.slots_booked }}</strong> / {{ event.slots_total }} | Frei: <strong>{{ event.slots_left }}</strong></div>
|
<div class="small mb-2">Gebucht: <strong>{{ event.slots_booked }}</strong> / {{ event.slots_total }} | Frei: <strong>{{ event.slots_left }}</strong></div>
|
||||||
<div class="d-flex flex-wrap gap-2 justify-content-lg-end">
|
<div class="d-flex flex-wrap gap-2 justify-content-lg-end">
|
||||||
<a class="btn btn-sm btn-primary" href="{{ event.link }}" target="_blank" rel="noopener">Client-Link öffnen</a>
|
|
||||||
|
<!-- Hinzugefügt: Direkter PDF-Export auch bei bestehenden Plänen -->
|
||||||
|
<a class="btn btn-sm btn-outline-success" href="{{ url_for('terminplaner.export_pdf_brief', plan_id=event.appointment_id) }}" title="Brief herunterladen">
|
||||||
|
📄 PDF
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="btn btn-sm btn-primary" href="{{ event.link }}" target="_blank" rel="noopener">Client-Link</a>
|
||||||
|
|
||||||
{% if event.calendar_link %}
|
{% if event.calendar_link %}
|
||||||
<a class="btn btn-sm btn-outline-primary" href="{{ event.calendar_link }}">.ics</a>
|
<a class="btn btn-sm btn-outline-primary" href="{{ event.calendar_link }}">.ics</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="post" action="{{ url_for('terminplaner.delete_appointment', appointment_id=event.appointment_id, tenant=tenant_id) }}" class="d-inline" onsubmit="return confirm('Diesen Terminplan wirklich löschen?');">
|
<form method="post" action="{{ url_for('terminplaner.delete_appointment', appointment_id=event.appointment_id, tenant=tenant_id) }}" class="d-inline" onsubmit="return confirm('Diesen Terminplan wirklich löschen?');">
|
||||||
<button type="submit" class="btn btn-sm btn-outline-danger">Entfernen</button>
|
<button type="submit" class="btn btn-sm btn-outline-danger">Entfernen</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -105,4 +149,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -788,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>
|
||||||
@@ -942,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>
|
||||||
@@ -1563,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.');
|
||||||
@@ -1600,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);
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ services:
|
|||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
environment:
|
environment:
|
||||||
MONGO_INITDB_DATABASE: inventar_default
|
MONGO_INITDB_DATABASE: inventar_default
|
||||||
|
|
||||||
|
|||||||
+108
-136
@@ -23,70 +23,19 @@ ensure_runtime_config_json() {
|
|||||||
echo "Warning: moved unexpected directory $config_path to $backup_path"
|
echo "Warning: moved unexpected directory $config_path to $backup_path"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
FORCE_REMOVE=false
|
if [ ! -f "$config_path" ]; then
|
||||||
if [ "${2:-}" = "--yes" ] || [ "${2:-}" = "-y" ]; then
|
|
||||||
FORCE_REMOVE=true
|
|
||||||
TENANT_ID="${3:-}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$TENANT_ID" ]; then
|
|
||||||
cat > "$config_path" <<'EOF'
|
cat > "$config_path" <<'EOF'
|
||||||
{
|
{
|
||||||
"ver": "2.6.5",
|
"ver": "2.6.5",
|
||||||
|
"tenants": {}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
echo "Created default config.json"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
if [ "$FORCE_REMOVE" != true ]; then
|
get_tenant_aliases() {
|
||||||
echo -n "WARNING: Are you sure you want to permanently delete all data for tenant '$TENANT_ID'? (y/N) "
|
local tenant_id="$1"
|
||||||
read confirm
|
|
||||||
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
|
|
||||||
echo "Removal canceled."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Removing tenant '$TENANT_ID'..."
|
|
||||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
|
||||||
port_to_remove=""
|
|
||||||
if [ -n "$APP_CONTAINER" ]; then
|
|
||||||
port_to_remove="$({ docker exec "$APP_CONTAINER" python3 - "$TENANT_ID" <<'PY'
|
|
||||||
import sys
|
|
||||||
sys.path.insert(0, '/app')
|
|
||||||
sys.path.insert(0, '/app/Web')
|
|
||||||
from tenant import delete_tenant, get_tenant_config
|
|
||||||
|
|
||||||
tenant_id = sys.argv[1]
|
|
||||||
tenant_cfg = get_tenant_config(tenant_id)
|
|
||||||
port = tenant_cfg.get('port')
|
|
||||||
|
|
||||||
if not delete_tenant(tenant_id):
|
|
||||||
print(f'Error: failed to delete tenant {tenant_id}', file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if port is not None:
|
|
||||||
print(port)
|
|
||||||
PY
|
|
||||||
} 2>/dev/null)"
|
|
||||||
echo "Tenant '$TENANT_ID' database and config removed."
|
|
||||||
else
|
|
||||||
echo "Warning: Application container not running. Tenant database may still exist in MongoDB."
|
|
||||||
if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
|
|
||||||
:
|
|
||||||
else
|
|
||||||
echo "Warning: tenant '$TENANT_ID' was not configured in config.json or could not be removed."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$port_to_remove" ]; then
|
|
||||||
remove_runtime_port "$port_to_remove"
|
|
||||||
fi
|
|
||||||
sync_tenant_port_map
|
|
||||||
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
|
|
||||||
restart_app_container
|
|
||||||
fi
|
|
||||||
if [ -n "$port_to_remove" ]; then
|
|
||||||
echo "Removed tenant '$TENANT_ID' and cleaned runtime port $port_to_remove."
|
|
||||||
else
|
|
||||||
echo "Removed tenant '$TENANT_ID'. No port mapping was present."
|
|
||||||
fi
|
|
||||||
local normalized alias
|
local normalized alias
|
||||||
normalized="$(printf '%s' "$tenant_id" | tr '[:upper:]' '[:lower:]')"
|
normalized="$(printf '%s' "$tenant_id" | tr '[:upper:]' '[:lower:]')"
|
||||||
printf '%s\n' "$tenant_id"
|
printf '%s\n' "$tenant_id"
|
||||||
@@ -212,7 +161,7 @@ existing['modules'] = {
|
|||||||
'inventory': {'enabled': True},
|
'inventory': {'enabled': True},
|
||||||
'library': {'enabled': True},
|
'library': {'enabled': True},
|
||||||
'student_cards': {'enabled': True, 'default_borrow_days': 14, 'max_borrow_days': 365},
|
'student_cards': {'enabled': True, 'default_borrow_days': 14, 'max_borrow_days': 365},
|
||||||
'terminplan': {'enabled': True},
|
'terminplan': {'enabled': True},
|
||||||
'mail': {'enabled': True},
|
'mail': {'enabled': True},
|
||||||
}
|
}
|
||||||
existing['trial'] = trial_config
|
existing['trial'] = trial_config
|
||||||
@@ -245,8 +194,8 @@ initialize_tenant_database() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
docker exec "$APP_CONTAINER" python3 -c '
|
docker exec -i "$APP_CONTAINER" python3 - "$tenant_id" "$mode" <<'PY'
|
||||||
import sys, re, datetime, hashlib
|
import sys, os, re, datetime, hashlib
|
||||||
sys.path.insert(0, "/app")
|
sys.path.insert(0, "/app")
|
||||||
sys.path.insert(0, "/app/Web")
|
sys.path.insert(0, "/app/Web")
|
||||||
from Web.modules.database import settings
|
from Web.modules.database import settings
|
||||||
@@ -258,7 +207,11 @@ sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_")
|
|||||||
db_name = f"inventar_{sanitized}"
|
db_name = f"inventar_{sanitized}"
|
||||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||||
db = client[db_name]
|
db = client[db_name]
|
||||||
hashed_pw = hashlib.sha512("admin123".encode()).hexdigest()
|
|
||||||
|
pw_bytes = "admin123".encode("utf-8")
|
||||||
|
random_salt = os.urandom(16)
|
||||||
|
hashed = hashlib.scrypt(pw_bytes, salt=random_salt, n=16384, r=8, p=1)
|
||||||
|
hashed_pw_string = f"v1${random_salt.hex()}${hashed.hex()}"
|
||||||
|
|
||||||
action_permissions = {
|
action_permissions = {
|
||||||
"can_borrow": True,
|
"can_borrow": True,
|
||||||
@@ -294,7 +247,7 @@ page_permissions = {
|
|||||||
if db.users.count_documents({"Username": "admin"}) == 0:
|
if db.users.count_documents({"Username": "admin"}) == 0:
|
||||||
db.users.insert_one({
|
db.users.insert_one({
|
||||||
"Username": "admin",
|
"Username": "admin",
|
||||||
"Password": hashed_pw,
|
"Password": hashed_pw_string,
|
||||||
"Admin": True,
|
"Admin": True,
|
||||||
"active_ausleihung": None,
|
"active_ausleihung": None,
|
||||||
"name": "Admin",
|
"name": "Admin",
|
||||||
@@ -319,7 +272,7 @@ if mode == "trial":
|
|||||||
)
|
)
|
||||||
|
|
||||||
print(f"Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123")
|
print(f"Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123")
|
||||||
' "$tenant_id" "$mode"
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
update_runtime_ports() {
|
update_runtime_ports() {
|
||||||
@@ -435,7 +388,6 @@ restart_app_container() {
|
|||||||
|
|
||||||
ensure_runtime_config_json
|
ensure_runtime_config_json
|
||||||
|
|
||||||
# If HOST_WORKDIR is set (called from container), use absolute paths so docker daemon resolves them correctly
|
|
||||||
if [ -n "${HOST_WORKDIR:-}" ]; then
|
if [ -n "${HOST_WORKDIR:-}" ]; then
|
||||||
workdir="$HOST_WORKDIR"
|
workdir="$HOST_WORKDIR"
|
||||||
compose_args+=( -f "$(readlink -f "$HOST_WORKDIR/docker-compose-multitenant.yml")" )
|
compose_args+=( -f "$(readlink -f "$HOST_WORKDIR/docker-compose-multitenant.yml")" )
|
||||||
@@ -446,7 +398,6 @@ restart_app_container() {
|
|||||||
compose_args+=( --env-file "$(readlink -f "$HOST_WORKDIR/.docker-build.env")" )
|
compose_args+=( --env-file "$(readlink -f "$HOST_WORKDIR/.docker-build.env")" )
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
# Normal case: called directly from host
|
|
||||||
compose_args+=( -f "$workdir/docker-compose-multitenant.yml" )
|
compose_args+=( -f "$workdir/docker-compose-multitenant.yml" )
|
||||||
if [ -f "$workdir/.docker-compose.runtime.override.yml" ]; then
|
if [ -f "$workdir/.docker-compose.runtime.override.yml" ]; then
|
||||||
compose_args+=( -f "$workdir/.docker-compose.runtime.override.yml" )
|
compose_args+=( -f "$workdir/.docker-compose.runtime.override.yml" )
|
||||||
@@ -456,7 +407,6 @@ restart_app_container() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Pass along COMPOSE_PROJECT_NAME if set so the internal docker-compose sees it
|
|
||||||
if [ -n "${COMPOSE_PROJECT_NAME:-}" ]; then
|
if [ -n "${COMPOSE_PROJECT_NAME:-}" ]; then
|
||||||
compose_args=( -p "$COMPOSE_PROJECT_NAME" "${compose_args[@]}" )
|
compose_args=( -p "$COMPOSE_PROJECT_NAME" "${compose_args[@]}" )
|
||||||
fi
|
fi
|
||||||
@@ -566,24 +516,83 @@ remove_runtime_port() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
show_help() {
|
||||||
|
local HEADER='\033[95m'
|
||||||
|
local BLUE='\033[94m'
|
||||||
|
local GREEN='\033[92m'
|
||||||
|
local YELLOW='\033[93m'
|
||||||
|
local RED='\033[91m'
|
||||||
|
local BOLD='\033[1m'
|
||||||
|
local RESET='\033[0m'
|
||||||
|
|
||||||
|
cat << EOF
|
||||||
|
|
||||||
|
${HEADER}${BOLD}=== MULTI-TENANT INVENTAR MANAGER ===${RESET}
|
||||||
|
${YELLOW}Nutzung:${RESET} $0 <befehl> [tenant_id] [optionen]
|
||||||
|
|
||||||
|
${BLUE}${BOLD}VERFÜGBARE BEFEHLE:${RESET}
|
||||||
|
${GREEN}add${RESET} <tenant_id> [port]
|
||||||
|
Legt einen neuen Tenant an, registriert den Port und initialisiert
|
||||||
|
die MongoDB-Datenbank mit einem Standard-Admin (${YELLOW}admin / admin123${RESET}).
|
||||||
|
|
||||||
|
${GREEN}trial${RESET} <tenant_id> [port] [tage]
|
||||||
|
Erstellt einen temporären Test-Tenant. Standardlaufzeit: 7 Tage.
|
||||||
|
Läuft automatisch ab und löscht sich selbst.
|
||||||
|
|
||||||
|
${GREEN}remove${RESET} [-y|--yes] <tenant_id>
|
||||||
|
Löscht einen Tenant, seine Konfiguration, Ports und die Datenbank.
|
||||||
|
Nutze ${RED}-y${RESET}, um die Bestätigungsabfrage zu überspringen.
|
||||||
|
|
||||||
|
${GREEN}restart-tenant${RESET} <tenant_id>
|
||||||
|
Startet einen spezifischen Tenant neu, indem alle aktiven Sessions
|
||||||
|
und der Cache in der MongoDB geleert werden (Sitzungs-Reset).
|
||||||
|
|
||||||
|
${GREEN}restart-all${RESET}
|
||||||
|
Führt einen Zero-Downtime Rolling-Restart für alle App-Container durch.
|
||||||
|
|
||||||
|
${GREEN}list${RESET}
|
||||||
|
Listet alle registrierten Tenants aus der 'config.json' sowie alle
|
||||||
|
aktiven Tenant-Datenbanken aus der MongoDB auf.
|
||||||
|
|
||||||
|
${GREEN}module${RESET} <tenant_id> <modulname>=<on|off> ...
|
||||||
|
Aktiviert oder deaktiviert bestimmte Features/Module für einen Tenant.
|
||||||
|
Es können mehrere Module gleichzeitig übergeben werden.
|
||||||
|
|
||||||
|
${BLUE}${BOLD}GLOBALE OPTIONEN:${RESET}
|
||||||
|
${GREEN}-h, --help${RESET}
|
||||||
|
Zeigt diese Hilfe an.
|
||||||
|
|
||||||
|
${YELLOW}Beispiele:${RESET}
|
||||||
|
$0 add schule_muenchen 8081
|
||||||
|
$0 trial test_user 8082 14
|
||||||
|
$0 module schule_muenchen barre_code=on leih_historie=off
|
||||||
|
$0 remove -y test_user
|
||||||
|
|
||||||
|
-----------------------------------------------------------------
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
if [ -z "${1:-}" ]; then
|
if [ -z "${1:-}" ]; then
|
||||||
show_help
|
show_help
|
||||||
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
COMMAND="$1"
|
COMMAND="$1"
|
||||||
TENANT_ID="${2:-}"
|
|
||||||
|
# === MAIN ROUTING BLOCK ===
|
||||||
|
|
||||||
case "$COMMAND" in
|
case "$COMMAND" in
|
||||||
-h|--help)
|
-h|--help)
|
||||||
show_help
|
show_help
|
||||||
;;
|
;;
|
||||||
add)
|
add)
|
||||||
|
TENANT_ID="${2:-}"
|
||||||
if [ -z "$TENANT_ID" ]; then
|
if [ -z "$TENANT_ID" ]; then
|
||||||
echo "Error: Please provide a tenant_id."
|
echo "Error: Please provide a tenant_id."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
PORT_ARG="$3"
|
PORT_ARG="${3:-}"
|
||||||
if [ -n "$PORT_ARG" ]; then
|
if [ -n "$PORT_ARG" ]; then
|
||||||
if ! printf '%s\n' "$PORT_ARG" | grep -qE '^[0-9]+$'; then
|
if ! printf '%s\n' "$PORT_ARG" | grep -qE '^[0-9]+$'; then
|
||||||
echo "Error: Port must be a numeric value."
|
echo "Error: Port must be a numeric value."
|
||||||
@@ -598,68 +607,13 @@ case "$COMMAND" in
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Adding new tenant '$TENANT_ID'..."
|
echo "Adding new tenant '$TENANT_ID'..."
|
||||||
# Initialize tenant database via Python inside container
|
|
||||||
echo "Initializing database for $TENANT_ID..."
|
echo "Initializing database for $TENANT_ID..."
|
||||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
initialize_tenant_database "$TENANT_ID" "standard"
|
||||||
if [ -n "$APP_CONTAINER" ]; then
|
echo "Tenant '$TENANT_ID' successfully added. Ready to use."
|
||||||
docker exec $APP_CONTAINER python3 -c "
|
|
||||||
import sys, re; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from Web.modules.database import settings; from pymongo import MongoClient; import hashlib
|
|
||||||
tenant_id = sys.argv[1].lower()
|
|
||||||
sanitized = ''.join(c for c in tenant_id if c.isalnum() or c == '_')
|
|
||||||
db_name = f'inventar_{sanitized}'
|
|
||||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
|
||||||
db = client[db_name]
|
|
||||||
hashed_pw = hashlib.sha512('admin123'.encode()).hexdigest()
|
|
||||||
if db.users.count_documents({'Username': 'admin'}) == 0:
|
|
||||||
db.users.insert_one({
|
|
||||||
'Username': 'admin',
|
|
||||||
'Password': hashed_pw,
|
|
||||||
'Admin': True,
|
|
||||||
'active_ausleihung': None,
|
|
||||||
'name': 'Admin',
|
|
||||||
'last_name': 'User',
|
|
||||||
'IsStudent': False,
|
|
||||||
'PermissionPreset': 'full_access',
|
|
||||||
'ActionPermissions': {
|
|
||||||
'can_borrow': True,
|
|
||||||
'can_insert': True,
|
|
||||||
'can_edit': True,
|
|
||||||
'can_delete': True,
|
|
||||||
'can_manage_users': True,
|
|
||||||
'can_manage_settings': True,
|
|
||||||
'can_view_logs': True,
|
|
||||||
},
|
|
||||||
'PagePermissions': {
|
|
||||||
'home': True,
|
|
||||||
'tutorial_page': True,
|
|
||||||
'my_borrowed_items': True,
|
|
||||||
'notifications_view': True,
|
|
||||||
'impressum': True,
|
|
||||||
'license': True,
|
|
||||||
'library_view': True,
|
|
||||||
'terminplan': True,
|
|
||||||
'home_admin': True,
|
|
||||||
'upload_admin': True,
|
|
||||||
'library_admin': True,
|
|
||||||
'admin_borrowings': True,
|
|
||||||
'library_loans_admin': True,
|
|
||||||
'admin_damaged_items': True,
|
|
||||||
'admin_audit_dashboard': True,
|
|
||||||
'logs': True,
|
|
||||||
'manage_filters': True,
|
|
||||||
'manage_locations': True,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123')
|
|
||||||
" "$TENANT_ID"
|
|
||||||
echo "Tenant '$TENANT_ID' successfully added. Ready to use."
|
|
||||||
else
|
|
||||||
echo "Warning: Application container is not running. Please start the multi-tenant system first."
|
|
||||||
echo "Data will be initialized upon first access by the tenant."
|
|
||||||
fi
|
|
||||||
;;
|
;;
|
||||||
|
|
||||||
trial)
|
trial)
|
||||||
|
TENANT_ID="${2:-}"
|
||||||
if [ -z "$TENANT_ID" ]; then
|
if [ -z "$TENANT_ID" ]; then
|
||||||
echo "Error: Please provide a tenant_id."
|
echo "Error: Please provide a tenant_id."
|
||||||
exit 1
|
exit 1
|
||||||
@@ -691,9 +645,13 @@ print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin1
|
|||||||
|
|
||||||
remove)
|
remove)
|
||||||
FORCE_REMOVE=false
|
FORCE_REMOVE=false
|
||||||
if [ "${2:-}" = "--yes" ] || [ "${2:-}" = "-y" ]; then
|
TENANT_ARG="${2:-}"
|
||||||
|
|
||||||
|
if [ "$TENANT_ARG" = "--yes" ] || [ "$TENANT_ARG" = "-y" ]; then
|
||||||
FORCE_REMOVE=true
|
FORCE_REMOVE=true
|
||||||
TENANT_ID="${3:-}"
|
TENANT_ID="${3:-}"
|
||||||
|
else
|
||||||
|
TENANT_ID="$TENANT_ARG"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "$TENANT_ID" ]; then
|
if [ -z "$TENANT_ID" ]; then
|
||||||
@@ -713,17 +671,32 @@ print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin1
|
|||||||
echo "Removing tenant '$TENANT_ID'..."
|
echo "Removing tenant '$TENANT_ID'..."
|
||||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||||
port_to_remove=""
|
port_to_remove=""
|
||||||
|
|
||||||
if [ -n "$APP_CONTAINER" ]; then
|
if [ -n "$APP_CONTAINER" ]; then
|
||||||
|
# Zuerst die Datenbank via PyMongo hart droppen (Erzwungenes Löschen)
|
||||||
port_to_remove="$(docker exec "$APP_CONTAINER" python3 - "$TENANT_ID" <<'PY'
|
port_to_remove="$(docker exec "$APP_CONTAINER" python3 - "$TENANT_ID" <<'PY'
|
||||||
import sys
|
import sys, re
|
||||||
sys.path.insert(0, '/app')
|
sys.path.insert(0, '/app')
|
||||||
sys.path.insert(0, '/app/Web')
|
sys.path.insert(0, '/app/Web')
|
||||||
from tenant import delete_tenant, get_tenant_config
|
from tenant import delete_tenant, get_tenant_config
|
||||||
|
from Web.modules.database import settings
|
||||||
|
from pymongo import MongoClient
|
||||||
|
|
||||||
tenant_id = sys.argv[1]
|
tenant_id = sys.argv[1]
|
||||||
tenant_cfg = get_tenant_config(tenant_id)
|
tenant_cfg = get_tenant_config(tenant_id)
|
||||||
port = tenant_cfg.get('port')
|
port = tenant_cfg.get('port')
|
||||||
|
|
||||||
|
# Datenbanknamen exakt rekonstruieren
|
||||||
|
sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_")
|
||||||
|
db_name = f"inventar_{sanitized}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||||
|
client.drop_database(db_name)
|
||||||
|
print(f"MongoDB database '{db_name}' dropped successfully.", file=sys.stderr)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not drop database '{db_name}': {e}", file=sys.stderr)
|
||||||
|
|
||||||
if not delete_tenant(tenant_id):
|
if not delete_tenant(tenant_id):
|
||||||
print(f'Error: failed to delete tenant {tenant_id}', file=sys.stderr)
|
print(f'Error: failed to delete tenant {tenant_id}', file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -757,20 +730,19 @@ PY
|
|||||||
;;
|
;;
|
||||||
|
|
||||||
restart-tenant)
|
restart-tenant)
|
||||||
|
TENANT_ID="${2:-}"
|
||||||
if [ -z "$TENANT_ID" ]; then
|
if [ -z "$TENANT_ID" ]; then
|
||||||
echo "Error: Please provide a tenant_id."
|
echo "Error: Please provide a tenant_id."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Restarting tenant '$TENANT_ID' (clearing session/cache)..."
|
echo "Restarting tenant '$TENANT_ID' (clearing session/cache)..."
|
||||||
# To restart a single tenant without restarting the global python processes,
|
|
||||||
# we can invalidate their cache or drop their sessions collection to sign everyone out
|
|
||||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||||
if [ -n "$APP_CONTAINER" ]; then
|
if [ -n "$APP_CONTAINER" ]; then
|
||||||
docker exec $APP_CONTAINER python3 -c "
|
docker exec $APP_CONTAINER python3 -c "
|
||||||
import sys; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from Web.modules.database import settings; from pymongo import MongoClient
|
import sys; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from Web.modules.database import settings; from pymongo import MongoClient
|
||||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||||
db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}']
|
db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}']
|
||||||
db.sessions.drop() # Force sign-out / session clear
|
db.sessions.drop()
|
||||||
print(f'Tenant {sys.argv[1]} session cache cleared. Tenant restarted.')
|
print(f'Tenant {sys.argv[1]} session cache cleared. Tenant restarted.')
|
||||||
" "$TENANT_ID"
|
" "$TENANT_ID"
|
||||||
echo "Tenant '$TENANT_ID' has been refreshed without impacting others."
|
echo "Tenant '$TENANT_ID' has been refreshed without impacting others."
|
||||||
@@ -833,12 +805,12 @@ PY
|
|||||||
;;
|
;;
|
||||||
|
|
||||||
module)
|
module)
|
||||||
|
TENANT_ID="${2:-}"
|
||||||
if [ -z "$TENANT_ID" ] || [ -z "${3:-}" ]; then
|
if [ -z "$TENANT_ID" ] || [ -z "${3:-}" ]; then
|
||||||
echo "Error: Usage: manage-tenant.sh module <tenant_id> <module_name>=<on|off> [...]"
|
echo "Error: Usage: manage-tenant.sh module <tenant_id> <module_name>=<on|off> [...]"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Pass all remaining arguments to python script
|
|
||||||
shift 2
|
shift 2
|
||||||
|
|
||||||
if python3 - "$CONFIG_FILE" "$TENANT_ID" "$@" <<'PY'
|
if python3 - "$CONFIG_FILE" "$TENANT_ID" "$@" <<'PY'
|
||||||
@@ -914,4 +886,4 @@ PY
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
@@ -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