Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+1
-2
@@ -8,5 +8,4 @@ INVENTAR_APP_MEM_SWAP_LIMIT=768m
|
||||
INVENTAR_WORKERS=4
|
||||
INVENTAR_THREADS=2
|
||||
INVENTAR_WORKER_TIMEOUT=30
|
||||
INVENTAR_WORKER_CONNECTIONS=100
|
||||
|
||||
INVENTAR_WORKER_CONNECTIONS=100
|
||||
@@ -1,5 +1,7 @@
|
||||
services:
|
||||
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
|
||||
build: null
|
||||
ports:
|
||||
|
||||
@@ -556,6 +556,14 @@ sudo chmod 644 certs/inventarsystem.crt
|
||||
|
||||
## 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
|
||||
|
||||
```bash
|
||||
|
||||
+136
-90
@@ -731,6 +731,7 @@ AUDIT_INDEXES_READY = False
|
||||
|
||||
def _ensure_audit_indexes_once():
|
||||
"""Ensure audit indexes exist once per process."""
|
||||
global AUDIT_INDEXES_READY
|
||||
if AUDIT_INDEXES_READY:
|
||||
return
|
||||
|
||||
@@ -1736,7 +1737,6 @@ def is_valid_isbn13(isbn13):
|
||||
check_digit = (10 - (checksum % 10)) % 10
|
||||
return check_digit == int(isbn13[12])
|
||||
|
||||
|
||||
def normalize_and_validate_isbn(isbn_raw):
|
||||
"""Normalize ISBN and return a valid canonical ISBN-13/10 or empty string."""
|
||||
isbn = normalize_isbn(isbn_raw)
|
||||
@@ -1996,7 +1996,7 @@ def _upload_student_cards_excel():
|
||||
synonyms = {
|
||||
'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'],
|
||||
'first_name': ['vorname', 'first_name', 'firstname'],
|
||||
'first_name': ['vorname', 'first_name', 'firstname', 'rufname'],
|
||||
'last_name': ['nachname', 'last_name', 'lastname'],
|
||||
'class_name': ['klasse', 'class', 'class_name', 'jahrgang', 'jahrgangsstufe', 'stufe', 'gruppe', 'asv_klasse'],
|
||||
'notes': ['notizen', 'notes', 'bemerkungen', 'bemerkung', 'hinweis', 'hinweise'],
|
||||
@@ -3302,7 +3302,13 @@ def api_library_items():
|
||||
borrower = doc.get('User', '')
|
||||
# Decrypt if value is encrypted (decrypt_text returns original if not encrypted)
|
||||
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:
|
||||
# Fallback: keep original string if decryption fails
|
||||
borrower = borrower or ''
|
||||
@@ -3821,7 +3827,7 @@ def library_admin():
|
||||
show_library_features=True,
|
||||
upload_mode='library',
|
||||
page_title='Bücher hochladen',
|
||||
back_target='home_admin'
|
||||
back_target='library'
|
||||
)
|
||||
|
||||
|
||||
@@ -4124,10 +4130,13 @@ def student_card_barcode_download():
|
||||
c.setLineWidth(2)
|
||||
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
|
||||
c.setFont("Helvetica-Bold", 9)
|
||||
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
|
||||
c.setFillColor(text_dark)
|
||||
@@ -4151,7 +4160,11 @@ def student_card_barcode_download():
|
||||
c.setFillColor(text_dark)
|
||||
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 - 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
|
||||
barcode_x_start = x_pos + info_width + 1*mm
|
||||
c.setFillColor(accent_color)
|
||||
@@ -4291,10 +4304,12 @@ def student_card_single_barcode_download(card_id):
|
||||
c.setLineWidth(2.5)
|
||||
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
|
||||
c.setFont("Helvetica-Bold", 11)
|
||||
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
|
||||
c.setFillColor(text_dark)
|
||||
@@ -5065,7 +5080,7 @@ def upload_item():
|
||||
Enhanced for mobile browser compatibility.
|
||||
|
||||
Returns:
|
||||
flask.Response: Redirect to admin homepage or JSON response
|
||||
flask.Response: Redirect to admin homepage
|
||||
"""
|
||||
# Check if the user is authenticated
|
||||
if 'username' not in session:
|
||||
@@ -5078,7 +5093,12 @@ def upload_item():
|
||||
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')
|
||||
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
|
||||
is_mobile = 'Mobile' in request.headers.get('User-Agent', '')
|
||||
@@ -5107,6 +5127,7 @@ def upload_item():
|
||||
upload_mode = sanitize_form_value(request.form.get('upload_mode', 'item'))
|
||||
individual_codes_raw = sanitize_form_value(request.form.get('individual_codes', ''))
|
||||
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:
|
||||
item_count = int(item_count_raw) if item_count_raw else 1
|
||||
@@ -5186,6 +5207,8 @@ def upload_item():
|
||||
item_isbn = isbn_raw
|
||||
if upload_mode == 'library':
|
||||
item_type = 'book'
|
||||
if item_type_input != "":
|
||||
item_type = item_type_input
|
||||
|
||||
if upload_mode == 'library':
|
||||
if not cfg.MODULES.is_enabled('library'):
|
||||
@@ -6032,22 +6055,9 @@ def upload_item():
|
||||
except Exception:
|
||||
app.logger.warning('Audit write failed for library_item_created')
|
||||
|
||||
if is_mobile:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'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)))
|
||||
|
||||
flash(success_msg, 'success')
|
||||
return redirect(url_for(success_redirect_endpoint, highlight_item=str(item_id)))
|
||||
else:
|
||||
error_msg = 'Fehler beim Hinzufügen des Elements'
|
||||
if is_mobile:
|
||||
@@ -8364,8 +8374,15 @@ def admin_create_invoice(borrow_id):
|
||||
flash('Für diese Ausleihe existiert bereits eine Rechnung. Bitte Korrekturbuchung verwenden.', 'warning')
|
||||
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)
|
||||
borrower = borrow_doc.get('User', '')
|
||||
item_name = item_doc.get('Name', '')
|
||||
item_code = item_doc.get('Code_4', '')
|
||||
|
||||
@@ -9542,8 +9559,8 @@ def _fetch_from_open_library(clean_isbn):
|
||||
|
||||
def _fetch_from_isbn_de(clean_isbn):
|
||||
"""
|
||||
Source 4: isbn.de (Web Scraping für deutsche Schulbücher)
|
||||
Robuste Version mit Fallbacks für Meta-Tags und Tabellendaten.
|
||||
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}"
|
||||
@@ -9552,94 +9569,115 @@ def _fetch_from_isbn_de(clean_isbn):
|
||||
}
|
||||
|
||||
response = requests.get(url, headers=headers, timeout=5)
|
||||
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
|
||||
# 1. Titel prüfen
|
||||
title_elem = soup.find('h1')
|
||||
if not title_elem or "nicht gefunden" in title_elem.text.lower() or "Suche" in title_elem.text:
|
||||
return None
|
||||
title = title_elem.text.strip()
|
||||
# 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"
|
||||
|
||||
# --- Hilfsfunktion für unstrukturierte HTML-Tabellen ---
|
||||
def get_detail_by_keyword(keywords):
|
||||
"""Sucht nach Schlagwörtern (z.B. 'Verlag') und gibt den angrenzenden Wert zurück."""
|
||||
for tag in soup.find_all(['th', 'td', 'strong', 'b', 'span']):
|
||||
text = tag.text.strip().lower()
|
||||
if any(kw in text for kw in keywords):
|
||||
# Wenn es eine Tabellenzelle (th/td) ist, nimm das nächste Geschwister-Element
|
||||
if tag.name in ['th', 'td']:
|
||||
sibling = tag.find_next_sibling('td')
|
||||
if sibling:
|
||||
return sibling.text.strip()
|
||||
# Wenn es ein Label in einem Listen- oder Absatz-Element ist
|
||||
parent = tag.parent
|
||||
if parent and parent.name in ['li', 'p', 'div']:
|
||||
return parent.text.replace(tag.text, '').strip()
|
||||
# Falls Fehler- oder Suchseite
|
||||
if "nicht gefunden" in title.lower() or "suche" in title.lower():
|
||||
return None
|
||||
|
||||
# --- Hilfsfunktion für Schema.org (itemprop) ---
|
||||
def get_itemprop(prop_name):
|
||||
elem = soup.find(attrs={"itemprop": prop_name})
|
||||
if elem:
|
||||
return elem.get('content') or elem.text.strip()
|
||||
# --- 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
|
||||
|
||||
authors = get_itemprop("author")
|
||||
if not authors:
|
||||
authors = get_detail_by_keyword(['autor', 'herausgeber', 'von:'])
|
||||
authors = authors if authors else "Unknown Author"
|
||||
|
||||
publisher = get_itemprop("publisher")
|
||||
# 2. Verlag holen
|
||||
publisher = get_sidebar_value('verlag')
|
||||
if not publisher:
|
||||
publisher = get_detail_by_keyword(['verlag'])
|
||||
publisher = publisher if publisher else "Unknown Publisher"
|
||||
publisher = "Unknown Publisher"
|
||||
|
||||
pub_date = get_itemprop("datePublished")
|
||||
if not pub_date:
|
||||
pub_date = get_detail_by_keyword(['erscheinungsjahr', 'erschienen', 'datum'])
|
||||
pub_date = pub_date if pub_date else "Unknown Date"
|
||||
# 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"
|
||||
|
||||
page_count = get_itemprop("numberOfPages")
|
||||
if not page_count:
|
||||
page_count = get_detail_by_keyword(['seiten', 'umfang'])
|
||||
|
||||
# 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"
|
||||
|
||||
description = get_itemprop("description")
|
||||
if not description:
|
||||
for class_name in ['description', 'zusammenfassung', 'klappentext', 'buch-beschreibung']:
|
||||
fallback_desc = soup.find('div', class_=re.compile(class_name, re.IGNORECASE))
|
||||
if fallback_desc:
|
||||
description = fallback_desc.text.strip()
|
||||
break
|
||||
description = description if description else "Keine Beschreibung verfügbar"
|
||||
# 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_elem = soup.find('img', itemprop="image")
|
||||
if not img_elem:
|
||||
img_elem = soup.find('img', class_=lambda c: c and 'cover' in str(c).lower())
|
||||
|
||||
if img_elem and 'src' in img_elem.attrs:
|
||||
thumbnail = img_elem['src']
|
||||
if thumbnail.startswith('/'):
|
||||
thumbnail = "https://www.isbn.de" + 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": authors,
|
||||
"authors": author,
|
||||
"publisher": publisher,
|
||||
"publishedDate": pub_date,
|
||||
"publishedDate": published_date,
|
||||
"description": description,
|
||||
"pageCount": page_count,
|
||||
"price": None,
|
||||
"price": price, # Gibt nun z.B. 12.25 als float zurück
|
||||
"thumbnail": thumbnail,
|
||||
"source": "isbn-de"
|
||||
}
|
||||
@@ -10239,10 +10277,18 @@ def notifications_unread_status():
|
||||
@app.route('/admin/damaged_items')
|
||||
def admin_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')
|
||||
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
|
||||
try:
|
||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||
|
||||
@@ -284,7 +284,6 @@ def _match_inventory(path):
|
||||
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'))
|
||||
|
||||
|
||||
def _match_terminplan(path):
|
||||
if not path:
|
||||
return False
|
||||
@@ -292,7 +291,7 @@ def _match_terminplan(path):
|
||||
|
||||
def _match_library(path):
|
||||
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):
|
||||
if not path: return False
|
||||
|
||||
@@ -38,7 +38,7 @@ def _active_record_query(extra_query=None):
|
||||
return base_query
|
||||
|
||||
|
||||
def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False, title: str=""):
|
||||
def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False, title: str="", custom_fields: list = ()):
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
@@ -54,8 +54,9 @@ def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
|
||||
'mail': mail,
|
||||
'note': note,
|
||||
'title': title,
|
||||
'custom_fields': custom_fields,
|
||||
'calendar_enabled': bool(calendar_enabled),
|
||||
'slots_booked': [], # -> [(start_time, name), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable
|
||||
'slots_booked': [], # -> [(start_time, name,(custom1, custom2,...)), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable
|
||||
'Created': datetime.datetime.now(),
|
||||
'LastUpdated': datetime.datetime.now()
|
||||
}
|
||||
|
||||
+90
-105
@@ -20,6 +20,8 @@ import string
|
||||
from bson.objectid import ObjectId
|
||||
import Web.modules.database.settings as cfg
|
||||
from Web.modules.database.settings import MongoClient
|
||||
import hmac
|
||||
import os
|
||||
|
||||
logger = logging.getLogger('app')
|
||||
logger.setLevel(logging.DEBUG)
|
||||
@@ -430,103 +432,84 @@ def check_password_strength(password):
|
||||
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:
|
||||
password (str): Password to hash
|
||||
|
||||
Returns:
|
||||
str: Hexadecimal digest of the hashed password
|
||||
if salt is None:
|
||||
# Neuer Benutzer / Passwortänderung -> Dynamischer Salt
|
||||
random_salt = os.urandom(16)
|
||||
hashed = hashlib.scrypt(password_bytes, salt=random_salt, n=16384, r=8, p=1)
|
||||
return f"v1${random_salt.hex()}${hashed.hex()}"
|
||||
else:
|
||||
# Bestehender Benutzer (wird zur Verifizierung aufgerufen)
|
||||
hashed = hashlib.scrypt(password_bytes, salt=salt, n=16384, r=8, p=1)
|
||||
return hashed.hex()
|
||||
|
||||
|
||||
def verify_password(provided_password, stored_password_string):
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
Verify username and password combination.
|
||||
|
||||
Args:
|
||||
username (str): Username to check
|
||||
password (str): Password to verify
|
||||
|
||||
Returns:
|
||||
dict: User document if credentials are valid, None otherwise
|
||||
Überprüft die Kombination aus Benutzername und Passwort (optimiert).
|
||||
"""
|
||||
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 _has_tenant_configs():
|
||||
logger.warning(
|
||||
"Refusing default DB fallback for login because tenant configs exist and no tenant was resolved."
|
||||
)
|
||||
logger.warning("Default DB fallback verweigert, da Tenant-Konfigurationen existieren.")
|
||||
return None
|
||||
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)
|
||||
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]
|
||||
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']
|
||||
|
||||
query = {'$or': [{'Username': username}, {'username': username}]}
|
||||
logger.debug("Running user lookup on %r: %s", db_name, query)
|
||||
user_record = users.find_one(query)
|
||||
|
||||
if user_record is None:
|
||||
logger.warning("No user document found in db=%r for username=%r", db_name, username)
|
||||
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)
|
||||
logger.warning("Kein Benutzer für %r in DB %r gefunden.", username, db_name)
|
||||
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')
|
||||
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
|
||||
|
||||
if stored_password != hashed_password:
|
||||
logger.warning(
|
||||
"Password mismatch for username=%r in db=%r: provided_hash=%s stored_hash=%s",
|
||||
username,
|
||||
db_name,
|
||||
hashed_password,
|
||||
stored_password,
|
||||
)
|
||||
return None
|
||||
# Automatische Migration alter Hashes auf das neue Format
|
||||
if not stored_password.startswith("v1$"):
|
||||
users.update_one({'_id': user_record['_id']}, {'$set': {'Password': hashing(password)}})
|
||||
|
||||
return user_record
|
||||
finally:
|
||||
@@ -556,44 +539,46 @@ def add_user(
|
||||
bool: True if user was added successfully, False if password was too weak
|
||||
"""
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
users = db['users']
|
||||
if not check_password_strength(password):
|
||||
return False
|
||||
permission_defaults = build_default_permission_payload(permission_preset)
|
||||
if isinstance(action_permissions, dict):
|
||||
for key, value in action_permissions.items():
|
||||
permission_defaults['actions'][str(key)] = bool(value)
|
||||
if isinstance(page_permissions, dict):
|
||||
for key, value in page_permissions.items():
|
||||
permission_defaults['pages'][str(key)] = bool(value)
|
||||
try:
|
||||
db = _get_tenant_db(client)
|
||||
users = db['users']
|
||||
if not check_password_strength(password):
|
||||
return False
|
||||
permission_defaults = build_default_permission_payload(permission_preset)
|
||||
if isinstance(action_permissions, dict):
|
||||
for key, value in action_permissions.items():
|
||||
permission_defaults['actions'][str(key)] = bool(value)
|
||||
if isinstance(page_permissions, dict):
|
||||
for key, value in page_permissions.items():
|
||||
permission_defaults['pages'][str(key)] = bool(value)
|
||||
|
||||
user_doc = {
|
||||
'Username': username,
|
||||
'Password': hashing(password),
|
||||
'Admin': False,
|
||||
'active_ausleihung': None,
|
||||
'name': name.strip() if name else '',
|
||||
'last_name': last_name.strip() if last_name else '',
|
||||
'IsStudent': bool(is_student),
|
||||
'PermissionPreset': permission_defaults['preset'],
|
||||
'ActionPermissions': permission_defaults['actions'],
|
||||
'PagePermissions': permission_defaults['pages'],
|
||||
}
|
||||
user_doc = {
|
||||
'Username': username,
|
||||
'Password': hashing(password),
|
||||
'Admin': False,
|
||||
'active_ausleihung': None,
|
||||
'name': name.strip() if name else '',
|
||||
'last_name': last_name.strip() if last_name else '',
|
||||
'IsStudent': bool(is_student),
|
||||
'PermissionPreset': permission_defaults['preset'],
|
||||
'ActionPermissions': permission_defaults['actions'],
|
||||
'PagePermissions': permission_defaults['pages'],
|
||||
}
|
||||
|
||||
normalized_card = normalize_student_card_id(student_card_id)
|
||||
if bool(is_student):
|
||||
if normalized_card:
|
||||
user_doc['StudentCardId'] = normalized_card
|
||||
if max_borrow_days is not None:
|
||||
try:
|
||||
user_doc['MaxBorrowDays'] = int(max_borrow_days)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
normalized_card = normalize_student_card_id(student_card_id)
|
||||
if bool(is_student):
|
||||
if normalized_card:
|
||||
user_doc['StudentCardId'] = normalized_card
|
||||
if max_borrow_days is not None:
|
||||
try:
|
||||
user_doc['MaxBorrowDays'] = int(max_borrow_days)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
users.insert_one(user_doc)
|
||||
client.close()
|
||||
return True
|
||||
users.insert_one(user_doc)
|
||||
return True
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def student_card_exists(student_card_id):
|
||||
|
||||
@@ -198,7 +198,7 @@ def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str
|
||||
return '\r\n'.join(ics_lines)
|
||||
|
||||
|
||||
def new(date_start: str, date_end: str, time_span: list, slots, slot_length, user: str, mail: list=None, note:str="", calendar_enabled: bool=False, title: str="") -> dict:
|
||||
def new(date_start: str, date_end: str, time_span: list, slots, slot_length, user: str, mail: list=None, note:str="", calendar_enabled: bool=False, title: str="", custom_fields: list = ()) -> dict:
|
||||
"""
|
||||
Generates a link for the executive to send to his clients to book a time Slot
|
||||
"""
|
||||
@@ -207,6 +207,11 @@ def new(date_start: str, date_end: str, time_span: list, slots, slot_length, use
|
||||
except (ValueError, TypeError):
|
||||
slots_int = 0
|
||||
|
||||
try:
|
||||
custom_fields.pop(-1)
|
||||
except :
|
||||
pass
|
||||
|
||||
try:
|
||||
slot_length_int = int(slot_length)
|
||||
except (ValueError, TypeError):
|
||||
@@ -215,7 +220,7 @@ def new(date_start: str, date_end: str, time_span: list, slots, slot_length, use
|
||||
normalized_time_span = _normalize_time_span(time_span)
|
||||
normalized_mail = _normalize_mail_list(mail or [])
|
||||
|
||||
id = termin.add(date_start, date_end, normalized_time_span, slots_int, slot_length_int, user, normalized_mail, note, calendar_enabled=calendar_enabled, title=title)
|
||||
id = termin.add(date_start, date_end, normalized_time_span, slots_int, slot_length_int, user, normalized_mail, note, calendar_enabled=calendar_enabled, title=title, custom_fields=custom_fields)
|
||||
id_str = str(id)
|
||||
tenant_id = _current_tenant_id()
|
||||
|
||||
@@ -254,7 +259,7 @@ def new(date_start: str, date_end: str, time_span: list, slots, slot_length, use
|
||||
}
|
||||
|
||||
|
||||
def book_slot(id, date_start_time, name):
|
||||
def book_slot(id, date_start_time, name, custom:list=()):
|
||||
try:
|
||||
item = termin.get_item(id)
|
||||
if not item:
|
||||
@@ -273,7 +278,7 @@ def book_slot(id, date_start_time, name):
|
||||
if existing[0] == date_start_time and existing[1] == name:
|
||||
return False
|
||||
|
||||
slots.append((date_start_time, name))
|
||||
slots.append((date_start_time, name, custom))
|
||||
success = termin.update(id, slots)
|
||||
return bool(success)
|
||||
except Exception as e:
|
||||
@@ -418,6 +423,7 @@ def get_user_upcoming_events(user: str, limit: int = 25) -> list[dict]:
|
||||
'link': link,
|
||||
'calendar_link': calendar_link if item.get('calendar_enabled') else None,
|
||||
'title': str(item.get('title') or ''),
|
||||
'custom_fields': list(item.get('custom_fields')),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ def _current_tenant_id():
|
||||
@appoint_bp.route('/client/<appointment_id>', methods=['POST', 'GET'])
|
||||
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()
|
||||
if guard:
|
||||
@@ -50,6 +50,10 @@ def client(appointment_id):
|
||||
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()
|
||||
|
||||
# Extract the custom fields defined by the appointment owner
|
||||
custom_fields = appointment_item.get('custom_fields', [])
|
||||
|
||||
can_view_booking_names = False
|
||||
if current_user:
|
||||
try:
|
||||
@@ -70,31 +74,60 @@ def client(appointment_id):
|
||||
available_for_view['slots_booked'] = sanitized_bookings
|
||||
|
||||
if request.method == 'POST':
|
||||
start_daytime = request.form.get('start_day_time')
|
||||
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,
|
||||
)
|
||||
action = request.form.get('action', 'book')
|
||||
|
||||
if appointment_service.book_slot(appointment_id, start_daytime, username):
|
||||
return redirect(
|
||||
url_for(
|
||||
'terminplaner.client_success',
|
||||
# Fall 1: ADMIN/AUTOR storniert einen Termin
|
||||
if action == 'delete' and can_view_booking_names:
|
||||
slot_time = request.form.get('slot_time')
|
||||
client_name = request.form.get('target_client_name')
|
||||
|
||||
# Aktuelle Buchungen direkt aus dem DB-Item holen
|
||||
current_slots = appointment_item.get('slots_booked', []) or []
|
||||
|
||||
# Filtere den zu löschenden Slot heraus (Prüfung auf Zeit und Name)
|
||||
updated_slots = [
|
||||
slot for slot in current_slots
|
||||
if not (isinstance(slot, (list, tuple)) and slot[0] == slot_time and slot[1] == client_name)
|
||||
]
|
||||
|
||||
# In DB schreiben via deiner existierenden termin.update() Funktion
|
||||
if termin.update(appointment_id, updated_slots):
|
||||
flash('Buchung wurde erfolgreich gelöscht.', 'success')
|
||||
else:
|
||||
flash('Fehler beim Löschen der Buchung.', 'error')
|
||||
|
||||
return redirect(url_for('terminplaner.client', appointment_id=appointment_id, tenant=_current_tenant_id() or None))
|
||||
|
||||
# Fall 2: NORMALER CLIENT bucht einen Termin
|
||||
elif action == 'book':
|
||||
start_daytime = request.form.get('start_day_time')
|
||||
username = request.form.get('client_name')
|
||||
custom_answers = request.form.getlist('custom_answers')
|
||||
|
||||
if not start_daytime or not username:
|
||||
flash('Bitte Name und gewünschte Uhrzeit angeben.', 'error')
|
||||
return render_template(
|
||||
'termin_client.html',
|
||||
appointment_id=appointment_id,
|
||||
tenant=_current_tenant_id() or None,
|
||||
start=start_daytime,
|
||||
name=username,
|
||||
available=available_for_view,
|
||||
current_user=session.get('username', ''),
|
||||
tenant_id=_current_tenant_id(),
|
||||
can_view_booking_names=can_view_booking_names,
|
||||
custom_fields=custom_fields
|
||||
)
|
||||
)
|
||||
|
||||
flash('Der Termin konnte nicht gespeichert werden.', 'error')
|
||||
if appointment_service.book_slot(appointment_id, start_daytime, username, custom=custom_answers):
|
||||
return redirect(
|
||||
url_for(
|
||||
'terminplaner.client_success',
|
||||
appointment_id=appointment_id,
|
||||
tenant=_current_tenant_id() or None,
|
||||
start=start_daytime,
|
||||
name=username,
|
||||
)
|
||||
)
|
||||
|
||||
flash('Der Termin konnte nicht gespeichert werden.', 'error')
|
||||
|
||||
return render_template(
|
||||
'termin_client.html',
|
||||
@@ -103,6 +136,7 @@ def client(appointment_id):
|
||||
current_user=session.get('username', ''),
|
||||
tenant_id=_current_tenant_id(),
|
||||
can_view_booking_names=can_view_booking_names,
|
||||
custom_fields=custom_fields
|
||||
)
|
||||
|
||||
|
||||
@@ -169,13 +203,13 @@ def configure():
|
||||
end = request.form.get('end_date')
|
||||
time = request.form.get('time_frame')
|
||||
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', '')
|
||||
note = request.form.get('note', '')
|
||||
add_to_calendar = request.form.get('add_to_calendar') == 'on'
|
||||
title = request.form.get('title', '').strip() # Korrigiert: title statt titel
|
||||
title = request.form.get('title', '').strip()
|
||||
custom = request.form.getlist('custom_fields')
|
||||
|
||||
# Abfrage ebenfalls auf title und slot_length angepasst
|
||||
if not start or not end or not time or not slots_amount or not slot_length or not title:
|
||||
flash('Bitte alle Pflichtfelder ausfüllen.', 'error')
|
||||
return render_template(
|
||||
@@ -186,7 +220,7 @@ def configure():
|
||||
)
|
||||
|
||||
# Variablen im Funktionsaufruf aktualisiert
|
||||
result = appointment_service.new(start, end, time, slots_amount, slot_length, session["username"], mail, note, calendar_enabled=add_to_calendar, title=title)
|
||||
result = appointment_service.new(start, end, time, slots_amount, slot_length, session["username"], mail, note, calendar_enabled=add_to_calendar, title=title, custom_fields=custom)
|
||||
flash('Der Terminplan wurde angelegt.', 'success')
|
||||
return render_template(
|
||||
'termin_configure.html',
|
||||
|
||||
@@ -1321,10 +1321,7 @@
|
||||
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
|
||||
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
|
||||
{% if current_permissions.pages.get('library_loans_admin', True) %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Ausleihen</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>
|
||||
<li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Ausleihen / Defekte Items</a></li>
|
||||
{% endif %}
|
||||
{% if student_cards_module_enabled %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Bibliotheksausweis</a></li>
|
||||
|
||||
@@ -39,13 +39,19 @@
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-xxl-11">
|
||||
<div class="row g-4">
|
||||
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="card border-0 shadow-lg rounded-4 h-100">
|
||||
<div class="card-body p-4 p-md-5">
|
||||
<p class="text-uppercase text-muted fw-semibold mb-2">Terminplaner</p>
|
||||
<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>
|
||||
|
||||
{% if can_view_booking_names %}
|
||||
<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="fw-semibold">Zeitraum</div>
|
||||
<div>{{ available.date_start }} bis {{ available.date_end }}</div>
|
||||
@@ -58,23 +64,24 @@
|
||||
<div class="fw-semibold">Slot-Länge</div>
|
||||
<div>{{ available.slot_lenght }} Minuten</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% if not can_view_booking_names %}
|
||||
<div class="p-3 rounded-3 bg-light mb-3">
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
{% if available.slots_booked %}
|
||||
<div class="p-3 rounded-3 bg-light">
|
||||
<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 %}
|
||||
<li>
|
||||
{{ booking.start }}
|
||||
{% if can_view_booking_names and booking.name %}
|
||||
- {{ booking.name }}
|
||||
{% if booking is mapping %}
|
||||
{{ booking.get('start', '') }} - Belegt
|
||||
{% else %}
|
||||
- Belegt
|
||||
{{ booking[0] }} - {% if can_view_booking_names %}<span class="fw-semibold text-dark">{{ booking[1] }}</span>{% else %}Belegt{% endif %}
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
@@ -84,39 +91,143 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-8">
|
||||
<div class="card border-0 shadow-lg rounded-4 h-100">
|
||||
<div class="card-body p-4 p-md-5 bg-white">
|
||||
<h2 class="h4 fw-bold mb-3">Termin im Kalender auswählen</h2>
|
||||
|
||||
<div class="day-slider-wrap mb-3">
|
||||
<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>
|
||||
|
||||
{% if can_view_booking_names %}
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
|
||||
<div>
|
||||
<h2 class="h4 fw-bold mb-0">Eingegangene Buchungen</h2>
|
||||
<div class="text-muted small">Verwalten Sie die Termine Ihrer Klienten</div>
|
||||
</div>
|
||||
<span class="badge bg-primary rounded-pill fs-6 px-3 align-self-start align-self-md-center">
|
||||
{{ available.slots_total - available.slots_left }} von {{ available.slots_total }} belegt
|
||||
</span>
|
||||
</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>
|
||||
<div class="bg-light p-3 rounded-3 mb-4">
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-12 col-md-8">
|
||||
<label for="table-search" class="form-label small fw-semibold text-muted mb-1">Live-Filter (Name, Uhrzeit, Zusatzfelder...)</label>
|
||||
<input type="text" id="table-search" class="form-control form-control-lg bg-white border" placeholder="Suchbegriff eingeben...">
|
||||
</div>
|
||||
<div class="col-12 col-md-4 d-flex align-items-end h-100 mt-md-4">
|
||||
<button type="button" id="clear-filter-btn" class="btn btn-outline-secondary w-100 btn-lg">Filter zurücksetzen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{% if available.slots_booked %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle border-top" id="admin-booking-table">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th scope="col" class="py-3">Zeitpunkt</th>
|
||||
<th scope="col" class="py-3">Name</th>
|
||||
{% 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">
|
||||
{% for booking in available.slots_booked %}
|
||||
{% if booking is not mapping %}
|
||||
<tr>
|
||||
<td class="fw-semibold text-primary py-3 text-nowrap">
|
||||
{{ booking[0] }}
|
||||
</td>
|
||||
<td>
|
||||
<span class="fw-bold text-dark">{{ booking[1] }}</span>
|
||||
</td>
|
||||
|
||||
{% if booking|length > 2 and booking[2] %}
|
||||
{% for answer in booking[2] %}
|
||||
<td>{{ answer if answer else '<span class="text-muted small">-</span>'|safe }}</td>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% for field in custom_fields %}
|
||||
<td><span class="text-muted small">-</span></td>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<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>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</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 id="no-results-msg" class="text-center py-4 text-muted d-none">
|
||||
Keine Buchungen entsprechen Ihrem Filter.
|
||||
</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>
|
||||
{% 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>
|
||||
</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>
|
||||
@@ -142,6 +253,52 @@
|
||||
</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>
|
||||
(function () {
|
||||
|
||||
@@ -77,6 +77,16 @@
|
||||
<div class="form-text">Wird aus der gebuchten Zeit (abzüglich Pausen) und der Slot-Länge berechnet.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="custom-fields-container" class="mt-4">
|
||||
<h3 class="mb-3">Custom Fields</h3>
|
||||
|
||||
<div class="mb-3 custom-field-row">
|
||||
<input type="text"
|
||||
name="custom_fields"
|
||||
class="form-control form-control-lg custom-dynamic-input"
|
||||
placeholder="Zusatzfeld hinzufügen (z.B. Firma, Kundennummer...)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if mail_service_enabled %}
|
||||
<div>
|
||||
@@ -351,6 +361,39 @@
|
||||
syncTextarea();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const container = document.getElementById('custom-fields-container');
|
||||
|
||||
// Monitor inputs inside the container using event delegation
|
||||
container.addEventListener('input', function (event) {
|
||||
// Only trigger if the user is typing inside our dynamic inputs
|
||||
if (event.target.classList.contains('custom-dynamic-input')) {
|
||||
const allInputs = container.querySelectorAll('.custom-dynamic-input');
|
||||
const lastInput = allInputs[allInputs.length - 1];
|
||||
|
||||
// If the user typed something into the absolute last input field, spawn a new one
|
||||
if (lastInput.value.trim() !== '') {
|
||||
createNewFieldRow(container);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to generate and append a clean new input row
|
||||
function createNewFieldRow(targetContainer) {
|
||||
const rowDiv = document.createElement('div');
|
||||
rowDiv.className = 'mb-3 custom-field-row';
|
||||
|
||||
rowDiv.innerHTML = `
|
||||
<input type="text"
|
||||
name="custom_fields"
|
||||
class="form-control form-control-lg custom-dynamic-input"
|
||||
placeholder="Nächstes Zusatzfeld...">
|
||||
`;
|
||||
|
||||
targetContainer.appendChild(rowDiv);
|
||||
}
|
||||
});
|
||||
|
||||
// Event Listeners Registration
|
||||
buildButton.addEventListener('click', renderRows);
|
||||
startDateInput.addEventListener('change', renderRows);
|
||||
|
||||
@@ -788,7 +788,19 @@
|
||||
{% if show_library_features %}
|
||||
<!-- Library Mode: Single Customizable Filter -->
|
||||
<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">
|
||||
<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>
|
||||
@@ -942,7 +954,7 @@
|
||||
<div class="isbn-input-group">
|
||||
<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" 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 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>
|
||||
@@ -1563,6 +1575,7 @@
|
||||
// Get form fields
|
||||
const nameField = document.getElementById('name');
|
||||
const descriptionField = document.getElementById('beschreibung');
|
||||
const priceField = document.getElementById('anschaffungskosten'); // <-- NEU
|
||||
|
||||
if (!nameField || !descriptionField) {
|
||||
alert('Fehler: Formularfelder nicht gefunden.');
|
||||
@@ -1600,6 +1613,13 @@
|
||||
nameField.value = bookTitle;
|
||||
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
|
||||
if (currentBookData.thumbnail) {
|
||||
downloadBookCover(currentBookData.thumbnail);
|
||||
|
||||
@@ -55,6 +55,7 @@ services:
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
environment:
|
||||
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"
|
||||
fi
|
||||
|
||||
FORCE_REMOVE=false
|
||||
if [ "${2:-}" = "--yes" ] || [ "${2:-}" = "-y" ]; then
|
||||
FORCE_REMOVE=true
|
||||
TENANT_ID="${3:-}"
|
||||
fi
|
||||
|
||||
if [ -z "$TENANT_ID" ]; then
|
||||
if [ ! -f "$config_path" ]; then
|
||||
cat > "$config_path" <<'EOF'
|
||||
{
|
||||
"ver": "2.6.5",
|
||||
"tenants": {}
|
||||
}
|
||||
EOF
|
||||
echo "Created default config.json"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$FORCE_REMOVE" != true ]; then
|
||||
echo -n "WARNING: Are you sure you want to permanently delete all data for tenant '$TENANT_ID'? (y/N) "
|
||||
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
|
||||
get_tenant_aliases() {
|
||||
local tenant_id="$1"
|
||||
local normalized alias
|
||||
normalized="$(printf '%s' "$tenant_id" | tr '[:upper:]' '[:lower:]')"
|
||||
printf '%s\n' "$tenant_id"
|
||||
@@ -212,7 +161,7 @@ existing['modules'] = {
|
||||
'inventory': {'enabled': True},
|
||||
'library': {'enabled': True},
|
||||
'student_cards': {'enabled': True, 'default_borrow_days': 14, 'max_borrow_days': 365},
|
||||
'terminplan': {'enabled': True},
|
||||
'terminplan': {'enabled': True},
|
||||
'mail': {'enabled': True},
|
||||
}
|
||||
existing['trial'] = trial_config
|
||||
@@ -245,8 +194,8 @@ initialize_tenant_database() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
docker exec "$APP_CONTAINER" python3 -c '
|
||||
import sys, re, datetime, hashlib
|
||||
docker exec -i "$APP_CONTAINER" python3 - "$tenant_id" "$mode" <<'PY'
|
||||
import sys, os, re, datetime, hashlib
|
||||
sys.path.insert(0, "/app")
|
||||
sys.path.insert(0, "/app/Web")
|
||||
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}"
|
||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||
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 = {
|
||||
"can_borrow": True,
|
||||
@@ -294,7 +247,7 @@ page_permissions = {
|
||||
if db.users.count_documents({"Username": "admin"}) == 0:
|
||||
db.users.insert_one({
|
||||
"Username": "admin",
|
||||
"Password": hashed_pw,
|
||||
"Password": hashed_pw_string,
|
||||
"Admin": True,
|
||||
"active_ausleihung": None,
|
||||
"name": "Admin",
|
||||
@@ -319,7 +272,7 @@ if mode == "trial":
|
||||
)
|
||||
|
||||
print(f"Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123")
|
||||
' "$tenant_id" "$mode"
|
||||
PY
|
||||
}
|
||||
|
||||
update_runtime_ports() {
|
||||
@@ -435,7 +388,6 @@ restart_app_container() {
|
||||
|
||||
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
|
||||
workdir="$HOST_WORKDIR"
|
||||
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")" )
|
||||
fi
|
||||
else
|
||||
# Normal case: called directly from host
|
||||
compose_args+=( -f "$workdir/docker-compose-multitenant.yml" )
|
||||
if [ -f "$workdir/.docker-compose.runtime.override.yml" ]; then
|
||||
compose_args+=( -f "$workdir/.docker-compose.runtime.override.yml" )
|
||||
@@ -456,7 +407,6 @@ restart_app_container() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# Pass along COMPOSE_PROJECT_NAME if set so the internal docker-compose sees it
|
||||
if [ -n "${COMPOSE_PROJECT_NAME:-}" ]; then
|
||||
compose_args=( -p "$COMPOSE_PROJECT_NAME" "${compose_args[@]}" )
|
||||
fi
|
||||
@@ -566,24 +516,83 @@ remove_runtime_port() {
|
||||
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
|
||||
show_help
|
||||
exit 0
|
||||
fi
|
||||
|
||||
COMMAND="$1"
|
||||
TENANT_ID="${2:-}"
|
||||
|
||||
# === MAIN ROUTING BLOCK ===
|
||||
|
||||
case "$COMMAND" in
|
||||
-h|--help)
|
||||
show_help
|
||||
;;
|
||||
add)
|
||||
TENANT_ID="${2:-}"
|
||||
if [ -z "$TENANT_ID" ]; then
|
||||
echo "Error: Please provide a tenant_id."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PORT_ARG="$3"
|
||||
PORT_ARG="${3:-}"
|
||||
if [ -n "$PORT_ARG" ]; then
|
||||
if ! printf '%s\n' "$PORT_ARG" | grep -qE '^[0-9]+$'; then
|
||||
echo "Error: Port must be a numeric value."
|
||||
@@ -598,68 +607,13 @@ case "$COMMAND" in
|
||||
fi
|
||||
|
||||
echo "Adding new tenant '$TENANT_ID'..."
|
||||
# Initialize tenant database via Python inside container
|
||||
echo "Initializing database for $TENANT_ID..."
|
||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||
if [ -n "$APP_CONTAINER" ]; then
|
||||
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
|
||||
initialize_tenant_database "$TENANT_ID" "standard"
|
||||
echo "Tenant '$TENANT_ID' successfully added. Ready to use."
|
||||
;;
|
||||
|
||||
trial)
|
||||
TENANT_ID="${2:-}"
|
||||
if [ -z "$TENANT_ID" ]; then
|
||||
echo "Error: Please provide a tenant_id."
|
||||
exit 1
|
||||
@@ -691,9 +645,13 @@ print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin1
|
||||
|
||||
remove)
|
||||
FORCE_REMOVE=false
|
||||
if [ "${2:-}" = "--yes" ] || [ "${2:-}" = "-y" ]; then
|
||||
TENANT_ARG="${2:-}"
|
||||
|
||||
if [ "$TENANT_ARG" = "--yes" ] || [ "$TENANT_ARG" = "-y" ]; then
|
||||
FORCE_REMOVE=true
|
||||
TENANT_ID="${3:-}"
|
||||
else
|
||||
TENANT_ID="$TENANT_ARG"
|
||||
fi
|
||||
|
||||
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'..."
|
||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||
port_to_remove=""
|
||||
|
||||
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'
|
||||
import sys
|
||||
import sys, re
|
||||
sys.path.insert(0, '/app')
|
||||
sys.path.insert(0, '/app/Web')
|
||||
from tenant import delete_tenant, get_tenant_config
|
||||
from Web.modules.database import settings
|
||||
from pymongo import MongoClient
|
||||
|
||||
tenant_id = sys.argv[1]
|
||||
tenant_cfg = get_tenant_config(tenant_id)
|
||||
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):
|
||||
print(f'Error: failed to delete tenant {tenant_id}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -757,20 +730,19 @@ PY
|
||||
;;
|
||||
|
||||
restart-tenant)
|
||||
TENANT_ID="${2:-}"
|
||||
if [ -z "$TENANT_ID" ]; then
|
||||
echo "Error: Please provide a tenant_id."
|
||||
exit 1
|
||||
fi
|
||||
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)
|
||||
if [ -n "$APP_CONTAINER" ]; then
|
||||
docker exec $APP_CONTAINER python3 -c "
|
||||
import sys; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from Web.modules.database import settings; from pymongo import MongoClient
|
||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||
db = client[f'{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.')
|
||||
" "$TENANT_ID"
|
||||
echo "Tenant '$TENANT_ID' has been refreshed without impacting others."
|
||||
@@ -833,12 +805,12 @@ PY
|
||||
;;
|
||||
|
||||
module)
|
||||
TENANT_ID="${2:-}"
|
||||
if [ -z "$TENANT_ID" ] || [ -z "${3:-}" ]; then
|
||||
echo "Error: Usage: manage-tenant.sh module <tenant_id> <module_name>=<on|off> [...]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Pass all remaining arguments to python script
|
||||
shift 2
|
||||
|
||||
if python3 - "$CONFIG_FILE" "$TENANT_ID" "$@" <<'PY'
|
||||
@@ -914,4 +886,4 @@ PY
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
exit 0
|
||||
Reference in New Issue
Block a user