Compare commits

...

42 Commits

Author SHA1 Message Date
Aiirondev_dev 875cfa01a3 Some changes to the decryption of the Borrowers for the decryption of the USer 2026-06-25 15:24:23 +02:00
Aiirondev_dev c0c61a41a3 few needet repairs tro the return of the upload item funktion to not return json but a redirect 2026-06-25 15:02:00 +02:00
Aiirondev_dev 9e74d0c16d Adjustmentws to prevent over engeneering fo extra routes 2026-06-25 09:44:03 +02:00
Aiirondev_dev c8818edf0b better documentation for error finding and slight adjustments for library_loan_admin _ensure_audit_indexes_once 2026-06-25 09:37:44 +02:00
Aiirondev_dev b270bc9367 another relevant change to password security for the salt that is generated for every user Indivisdualy, to fix the temporary approach from before 2026-06-24 19:41:22 +02:00
Aiirondev_dev e1c284fc40 additional changes to the generatio of the tenant 2026-06-24 17:24:34 +02:00
Aiirondev_dev debd4dceb1 additional changes for the encoding 2026-06-24 17:02:56 +02:00
Aiirondev_dev db15df57c3 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-06-24 17:02:30 +02:00
Aiirondev_dev bcfe2095d5 slight fix of the encoding of the Processing for firsdt user generation 2026-06-24 17:01:20 +02:00
Aiirondev_dev 69ac6eca63 Some more fixes of the manage-tenants.sh file 2026-06-24 15:55:21 +02:00
Aiirondev_dev 717e3ce15e Merge remote-tracking branch 'refs/remotes/origin/main' 2026-06-24 12:51:33 +02:00
Aiirondev_dev 007ae04bf3 SIGHT CHANGES FOR THE CORRECT STARTUP OF THE DATABASE 2026-06-24 12:51:15 +02:00
Aiirondev_dev c0947c5cf1 fix for the adding process of manage-tenant 2026-06-24 00:38:47 +02:00
Aiirondev_dev c1d2935ad6 nother changes for the removal of the tenant Databases 2026-06-24 00:30:25 +02:00
Aiirondev_dev 9fde0b4b2a slight fixes of the removal process 2026-06-24 00:18:21 +02:00
Aiirondev_dev da7b075cc4 addition of thew help for tenant managment 2026-06-23 23:57:35 +02:00
Aiirondev_dev 347f258b8f adjustment of the encryption for user password 2026-06-23 22:41:53 +02:00
Aiirondev_dev 2421f867ed Smal changes for Module access 2026-06-23 14:40:27 +02:00
Aiirondev_dev 921915e92f new slight adjust of some namings in thze dropdown 2026-06-23 14:26:39 +02:00
Aiirondev_dev a4b0866293 permision changes for testing 2026-06-23 13:03:51 +02:00
Aiirondev_dev e10d86da4b manage te damaged Item ffield 2026-06-23 12:26:09 +02:00
Aiirondev_dev f160f23e9c sdmal changes 2026-06-23 12:01:23 +02:00
Aiirondev_dev ca272542de Merge remote-tracking branch 'refs/remotes/origin/main' 2026-06-22 00:21:26 +02:00
Aiirondev_dev 7df9b65389 Changes from sha512 to scrypt to fit with DS-GVO 2026-06-22 00:21:15 +02:00
Aiirondev_dev e4af76c9c1 smal change of the upload layout and text, changes of the Student Barcode download to alow further scoolyears 2026-06-20 13:54:42 +02:00
Aiirondev_dev f5944bf090 implementation of the Item Media Type 2026-06-20 12:01:21 +02:00
Aiirondev_dev 4e68da2368 Second Changes to include the Scool name into the Bibliotheksausweis 2026-06-18 13:09:57 +02:00
Aiirondev_dev 0d6aca4e8c added the school_name to the Schülerausweis 2026-06-18 12:56:10 +02:00
Aiirondev_dev 2bb0cbe599 Implmentation of a autonmatic integration of the price of a book 2026-06-17 16:27:02 +02:00
Aiirondev_dev 339487b4d4 Implementierung des Preses bei Fallback 4 2026-06-17 16:14:38 +02:00
Aiirondev_dev 3f90e2d4f1 Fixes for the secondary information getting with isbn.de 2026-06-17 16:07:37 +02:00
Aiirondev_dev b238b52fbf impoved request handeling for ISBN 2026-06-17 15:55:21 +02:00
Aiirondev_dev 545dd7783c New implementation of a nother scanning methode for ISBN 2026-06-17 11:10:53 +02:00
Aiirondev_dev f4ce1cf3b4 license Changes 2026-06-12 23:45:49 +02:00
Aiirondev_dev 7bd12bddaa Titel adding to the client version oif the termin 2026-06-12 22:14:19 +02:00
Aiirondev_dev b8e23b94d2 final changes for the changes to add the Titel to the Terminplanes 2026-06-12 22:00:20 +02:00
Aiirondev_dev 06b32b4a3d temporary debug mode 2026-06-12 21:45:06 +02:00
Aiirondev_dev d24bd6ba56 smal fixes for a error in the variable passing in the backend after changes in blueprint and terminconfig 2026-06-12 20:59:25 +02:00
Aiirondev_dev d1f48cf184 Name fixes 2026-06-12 20:50:31 +02:00
Aiirondev_dev 32fccd17ec some minor fixes 2026-06-12 20:43:51 +02:00
Aiirondev_dev 01447220a0 Some smal fixes for the Terminplaner 2026-06-12 20:34:01 +02:00
Aiirondev_dev b3aeb289ef required thingis removed 2026-06-12 19:59:05 +02:00
18 changed files with 618 additions and 413 deletions
-1
View File
@@ -9,4 +9,3 @@ 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
+2
View File
@@ -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:
+8
View File
@@ -556,6 +556,14 @@ sudo chmod 644 certs/inventarsystem.crt
## Fehlerbehebung ## Fehlerbehebung
### Logs Auslesen
```bash
docker logs inventarsystem-app-1
docker exec inventarsystem-app-1 cat /data/logs/application.log | tail -n 100
docker compose exec mongodb mongosh
```
### Webserver startet nicht ### Webserver startet nicht
```bash ```bash
+179 -24
View File
@@ -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,6 +4160,10 @@ 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
@@ -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,20 +6055,7 @@ 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({
'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') flash(success_msg, 'success')
return redirect(url_for(success_redirect_endpoint, highlight_item=str(item_id))) return redirect(url_for(success_redirect_endpoint, highlight_item=str(item_id)))
else: else:
@@ -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)
+1 -2
View File
@@ -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
+2 -1
View File
@@ -38,7 +38,7 @@ def _active_record_query(extra_query=None):
return base_query return base_query
def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False): 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=""):
try: try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client) db = _get_tenant_db(client)
@@ -53,6 +53,7 @@ def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
'user': user, 'user': user,
'mail': mail, 'mail': mail,
'note': note, 'note': note,
'title': title,
'calendar_enabled': bool(calendar_enabled), 'calendar_enabled': bool(calendar_enabled),
'slots_booked': [], # -> [(start_time, name), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable 'slots_booked': [], # -> [(start_time, name), ...]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(),
+57 -72
View File
@@ -20,6 +20,8 @@ import string
from bson.objectid import ObjectId from bson.objectid import ObjectId
import Web.modules.database.settings as cfg import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient from Web.modules.database.settings import MongoClient
import hmac
import os
logger = logging.getLogger('app') logger = logging.getLogger('app')
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
@@ -430,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).
Args: - Format für neue Hashes: v1$<salt_hex>$<hash_hex>
password (str): Password to hash
Returns:
str: Hexadecimal digest of the hashed password
""" """
return hashlib.sha512(password.encode()).hexdigest() password_bytes = password.encode('utf-8') # Explizit UTF-8 für Plattformunabhängigkeit
if salt is None:
# Neuer Benutzer / Passwortänderung -> Dynamischer Salt
random_salt = os.urandom(16)
hashed = hashlib.scrypt(password_bytes, salt=random_salt, n=16384, r=8, p=1)
return f"v1${random_salt.hex()}${hashed.hex()}"
else:
# Bestehender Benutzer (wird zur Verifizierung aufgerufen)
hashed = hashlib.scrypt(password_bytes, salt=salt, n=16384, r=8, p=1)
return hashed.hex()
def verify_password(provided_password, stored_password_string):
"""
Verifiziert ein Passwort gegen einen gespeicherten Hash-String.
Unterstützt das alte Format (statischer Salt) und das neue Format (v1$...).
"""
if not stored_password_string:
return False
# Überprüfung für das neue, sichere Format
if stored_password_string.startswith("v1$"):
try:
_, salt_hex, hash_hex = stored_password_string.split("$")
salt_bytes = bytes.fromhex(salt_hex)
# Berechne den Hash des eingegebenen Passworts mit dem extrahierten Salt
calculated_hash = hashing(provided_password, salt=salt_bytes)
# Timing-Attack-sicherer Vergleich
return hmac.compare_digest(calculated_hash, hash_hex)
except (ValueError, TypeError):
logger.error("Ungültiges Hash-Format in der Datenbank entdeckt.")
return False
else:
# Abwärtskompatibilität: Altes Format mit statischem Salt b'some_salt'
old_static_salt = b'some_salt'
calculated_hash = hashing(provided_password, salt=old_static_salt)
return hmac.compare_digest(calculated_hash, stored_password_string)
def check_nm_pwd(username, password): def check_nm_pwd(username, password):
""" """
Verify username and password combination. Überprüft die Kombination aus Benutzername und Passwort (optimiert).
Args:
username (str): Username to check
password (str): Password to verify
Returns:
dict: User document if credentials are valid, None otherwise
""" """
db_name, tenant_id = _resolve_request_tenant_db() db_name, tenant_id = _resolve_request_tenant_db()
ctx = None
try:
from tenant import get_tenant_context
ctx = get_tenant_context()
except Exception:
ctx = None
if not db_name: if not db_name:
if _has_tenant_configs(): if _has_tenant_configs():
logger.warning( logger.warning("Default DB fallback verweigert, da Tenant-Konfigurationen existieren.")
"Refusing default DB fallback for login because tenant configs exist and no tenant was resolved."
)
return None return None
db_name = cfg.MONGODB_DB db_name = cfg.MONGODB_DB
logger.info(
"check_nm_pwd start: username=%r tenant=%r db=%r host=%r port=%r uri=%r",
username,
tenant_id,
db_name,
cfg.MONGODB_HOST,
cfg.MONGODB_PORT,
getattr(cfg, 'MONGODB_URI', None),
)
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
try: try:
hashed_password = hashing(password)
logger.info("check_nm_pwd password hash for username=%r: %s", username, hashed_password)
available_dbs = []
try:
available_dbs = client.list_database_names()
logger.debug("MongoDB connected. Available databases=%s", available_dbs)
except Exception as exc:
logger.exception("Unable to list MongoDB databases: %s", exc)
db = client[db_name] db = client[db_name]
try:
existing_collections = db.list_collection_names()
except Exception as exc:
logger.exception("Unable to list collections for db=%r: %s", db_name, exc)
existing_collections = []
logger.debug("Tenant db=%r collections=%s", db_name, existing_collections)
users = db['users'] users = db['users']
query = {'$or': [{'Username': username}, {'username': username}]} query = {'$or': [{'Username': username}, {'username': username}]}
logger.debug("Running user lookup on %r: %s", db_name, query)
user_record = users.find_one(query) user_record = users.find_one(query)
if user_record is None: if user_record is None:
logger.warning("No user document found in db=%r for username=%r", db_name, username) logger.warning("Kein Benutzer für %r in DB %r gefunden.", username, db_name)
if db_name not in available_dbs:
logger.warning("Tenant database %r is missing from available MongoDB databases", db_name)
if 'users' not in existing_collections:
logger.warning("Tenant database %r has no users collection", db_name)
return None return None
logger.info("Found user document for username=%r in db=%r: %s", username, db_name, user_record)
stored_password = user_record.get('Password') or user_record.get('password') stored_password = user_record.get('Password') or user_record.get('password')
if stored_password is None:
logger.warning("User document for username=%r in db=%r has no password field", username, db_name) if not verify_password(password, stored_password):
logger.warning("Falsches Passwort für Benutzer %r in DB %r.", username, db_name)
return None return None
if stored_password != hashed_password: # Automatische Migration alter Hashes auf das neue Format
logger.warning( if not stored_password.startswith("v1$"):
"Password mismatch for username=%r in db=%r: provided_hash=%s stored_hash=%s", users.update_one({'_id': user_record['_id']}, {'$set': {'Password': hashing(password)}})
username,
db_name,
hashed_password,
stored_password,
)
return None
return user_record return user_record
finally: finally:
@@ -556,6 +539,7 @@ 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)
try:
db = _get_tenant_db(client) db = _get_tenant_db(client)
users = db['users'] users = db['users']
if not check_password_strength(password): if not check_password_strength(password):
@@ -592,8 +576,9 @@ def add_user(
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):
+23 -70
View File
@@ -146,9 +146,10 @@ def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str
return None return None
try: try:
slot_minutes = int(item.get('slot_lenght') or 0) slot_minutes = int(item.get('slot_length') or item.get('slot_lenght') or 0)
except Exception: except Exception:
slot_minutes = 0 slot_minutes = 0
if slot_minutes <= 0: if slot_minutes <= 0:
slot_minutes = 45 slot_minutes = 45
@@ -197,23 +198,24 @@ def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str
return '\r\n'.join(ics_lines) return '\r\n'.join(ics_lines)
def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False, 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="") -> dict:
""" """
Generates a link for the executive to send to his clients to book a time Slot Generates a link for the executive to send to his clients to book a time Slot
Input:
- date_start: start of the time frae area
- date_end: end of the time frame area
- time_span: Time window for the days as a list [(first day Time Frame), (second day Time frame), (third etc.)]
- slots: amount of slots that are available
- slot_lenght: the lenght of a slot in minutes
Output:
- link: The link for the user to send to the clients
""" """
try:
slots_int = int(slots)
except (ValueError, TypeError):
slots_int = 0
try:
slot_length_int = int(slot_length)
except (ValueError, TypeError):
slot_length_int = 45
normalized_time_span = _normalize_time_span(time_span) normalized_time_span = _normalize_time_span(time_span)
normalized_mail = _normalize_mail_list(mail) normalized_mail = _normalize_mail_list(mail or [])
id = termin.add(date_start, date_end, normalized_time_span, slots, slot_lenght, user, normalized_mail, note, calendar_enabled=calendar_enabled, 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)
id_str = str(id) id_str = str(id)
tenant_id = _current_tenant_id() tenant_id = _current_tenant_id()
@@ -224,9 +226,11 @@ def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
link = host + "/terminplaner/client/" + id_str link = host + "/terminplaner/client/" + id_str
if tenant_id: if tenant_id:
link += f"?tenant={tenant_id}" link += f"?tenant={tenant_id}"
subject = f"{title} - Bitte Termin vereinbaren" if title else f"Terminanfrage von {user} - Bitte Termin vereinbaren" subject = f"{title} - Bitte Termin vereinbaren" if title else f"Terminanfrage von {user} - Bitte Termin vereinbaren"
note_link = note + f"Bitte klicken sie auf den folgenden Link um einen Termin zu vereinbaren: {link}" note_link = note + f"Bitte klicken sie auf den folgenden Link um einen Termin zu vereinbaren: {link}"
calendar_link = None calendar_link = None
if calendar_enabled: if calendar_enabled:
try: try:
calendar_link = url_for('terminplaner.calendar_export', appointment_id=id_str, tenant=tenant_id or None, _external=True) calendar_link = url_for('terminplaner.calendar_export', appointment_id=id_str, tenant=tenant_id or None, _external=True)
@@ -251,19 +255,7 @@ def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
def book_slot(id, date_start_time, name): def book_slot(id, date_start_time, name):
"""
Updates slot for the booking per a id
Input:
- id: the id is the id you get from the
- date_start_time: the date of the booking that was selected with date and time
- name: name that the client gave himself
Output:
- bool: if worked or not
"""
try: try:
# Retrieve the current appointment
item = termin.get_item(id) item = termin.get_item(id)
if not item: if not item:
return False return False
@@ -281,10 +273,7 @@ def book_slot(id, date_start_time, name):
if existing[0] == date_start_time and existing[1] == name: if existing[0] == date_start_time and existing[1] == name:
return False return False
# Append the new booking as a tuple (start_time, name)
slots.append((date_start_time, name)) slots.append((date_start_time, name))
# Update the appointment in the database
success = termin.update(id, slots) success = termin.update(id, slots)
return bool(success) return bool(success)
except Exception as e: except Exception as e:
@@ -293,19 +282,12 @@ def book_slot(id, date_start_time, name):
def remove_slot(id, date_start_time, name): def remove_slot(id, date_start_time, name):
"""
Remove a booked slot for an appointment.
Returns True if the removal succeeded, False otherwise.
"""
try: try:
# Prefer DB-level remove if available
if hasattr(termin, 'remove_slot'): if hasattr(termin, 'remove_slot'):
removed = termin.remove_slot(id, date_start_time, name) removed = termin.remove_slot(id, date_start_time, name)
if removed: if removed:
return True return True
# Fallback: fetch, filter, and replace the slot list
item = termin.get_item(id) item = termin.get_item(id)
if not item: if not item:
return False return False
@@ -314,7 +296,6 @@ def remove_slot(id, date_start_time, name):
new_slots = [] new_slots = []
for s in slots: for s in slots:
try: try:
# s may be list or tuple like [start_time, name]
if isinstance(s, (list, tuple)) and len(s) >= 2 and s[0] == date_start_time and s[1] == name: if isinstance(s, (list, tuple)) and len(s) >= 2 and s[0] == date_start_time and s[1] == name:
continue continue
except Exception: except Exception:
@@ -329,9 +310,6 @@ def remove_slot(id, date_start_time, name):
def remove_appointment(id): def remove_appointment(id):
"""
Remove an entire appointment by id.
"""
try: try:
return bool(termin.remove(id)) return bool(termin.remove(id))
except Exception as e: except Exception as e:
@@ -339,17 +317,6 @@ def remove_appointment(id):
return False return False
def get_available(id): def get_available(id):
"""
Gets the available time slots -> more over it returns the time frame and the allready booked slots also the lenght.
And checks if there are slots left.
Input:
- id: id of the appointment
Output:
- dict: all the needet information -> [Start_date, End_date, (first day Time Frame,
second day Time frame, third etc.), slot lenght, (bookedslots -> list)]
"""
try: try:
termin_range = termin.get_item(id) termin_range = termin.get_item(id)
if not termin_range: if not termin_range:
@@ -358,22 +325,19 @@ def get_available(id):
date_start = termin_range.get('date_start') date_start = termin_range.get('date_start')
date_end = termin_range.get('date_end') date_end = termin_range.get('date_end')
time_span = termin_range.get('time_span', []) time_span = termin_range.get('time_span', [])
slot_lenght = termin_range.get('slot_lenght')
total_slots = termin_range.get('slots', 0)
# Ensure numeric fields are cast to int when stored as strings
try: try:
total_slots = int(termin_range.get('slots', 0) or 0) total_slots = int(termin_range.get('slots', 0) or 0)
except Exception: except Exception:
total_slots = 0 total_slots = 0
try: try:
slot_lenght = int(termin_range.get('slot_lenght') or 0) slot_length = int(termin_range.get('slot_length') or termin_range.get('slot_lenght') or 0)
except Exception: except Exception:
slot_lenght = termin_range.get('slot_lenght') slot_length = termin_range.get('slot_length') or termin_range.get('slot_lenght')
booked = termin_range.get('slots_booked', []) or [] booked = termin_range.get('slots_booked', []) or []
# Normalize booked entries to dicts for easier consumption
normalized = [] normalized = []
for s in booked: for s in booked:
if isinstance(s, (list, tuple)) and len(s) >= 2: if isinstance(s, (list, tuple)) and len(s) >= 2:
@@ -393,7 +357,8 @@ def get_available(id):
'date_start': date_start, 'date_start': date_start,
'date_end': date_end, 'date_end': date_end,
'time_span': time_span, 'time_span': time_span,
'slot_lenght': slot_lenght, 'slot_length': slot_length,
'slot_lenght': slot_length,
'slots_total': total_slots, 'slots_total': total_slots,
'slots_booked': normalized, 'slots_booked': normalized,
'slots_left': slots_left, 'slots_left': slots_left,
@@ -403,22 +368,10 @@ def get_available(id):
return {} return {}
def get_available_user(id): def get_available_user(id):
"""
Gets the available time slots -> more over it returns the time frame and the allready booked slots also the lenght.
And checks if there are slots left.
Input:
- id: id of the appointment
Output:
- dict: all the needet information -> [Start_date, End_date, (first day Time Frame,
second day Time frame, third etc.), slot lenght, (bookedslots -> list)]
"""
return get_available(id) return get_available(id)
def get_user_upcoming_events(user: str, limit: int = 25) -> list[dict]: def get_user_upcoming_events(user: str, limit: int = 25) -> list[dict]:
"""Return upcoming appointment plans for overview display."""
user_name = str(user or '').strip() user_name = str(user or '').strip()
if not user_name: if not user_name:
return [] return []
+7 -5
View File
@@ -169,13 +169,14 @@ def configure():
end = request.form.get('end_date') end = request.form.get('end_date')
time = request.form.get('time_frame') time = request.form.get('time_frame')
slots_amount = request.form.get('slots_amounts') slots_amount = request.form.get('slots_amounts')
slot_lenght = request.form.get('slot_lenght') slot_length = request.form.get('slot_length') # Korrigiert: 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'
titel = request.form.get('titel', '').strip() title = request.form.get('title', '').strip() # Korrigiert: title statt titel
if not start or not end or not time or not slots_amount or not slot_lenght or not titel: # 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') flash('Bitte alle Pflichtfelder ausfüllen.', 'error')
return render_template( return render_template(
'termin_configure.html', 'termin_configure.html',
@@ -184,7 +185,8 @@ def configure():
email_service_enabled=cfg.EMAIL_ENABLED, email_service_enabled=cfg.EMAIL_ENABLED,
) )
result = appointment_service.new(start, end, time, slots_amount, slot_lenght, session["username"], mail, note, calendar_enabled=add_to_calendar, title=titel) # 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)
flash('Der Terminplan wurde angelegt.', 'success') flash('Der Terminplan wurde angelegt.', 'success')
return render_template( return render_template(
'termin_configure.html', 'termin_configure.html',
@@ -193,7 +195,7 @@ def configure():
calendar_link=result.get('calendar_link'), calendar_link=result.get('calendar_link'),
add_to_calendar=add_to_calendar, add_to_calendar=add_to_calendar,
email_service_enabled=cfg.EMAIL_ENABLED, email_service_enabled=cfg.EMAIL_ENABLED,
title=titel, title=title,
) )
elif request.method == "GET": elif request.method == "GET":
return render_template( return render_template(
+1
View File
@@ -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
+1 -4
View File
@@ -1321,10 +1321,7 @@
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %} {% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li> <li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
{% if current_permissions.pages.get('library_loans_admin', True) %} {% if current_permissions.pages.get('library_loans_admin', True) %}
<li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Ausleihen</a></li> <li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Ausleihen / Defekte Items</a></li>
{% endif %}
{% if current_permissions.pages.get('admin_damaged_items', True) %}
<li><a class="dropdown-item" href="{{ url_for('admin_damaged_items') }}">Defekte Items</a></li>
{% endif %} {% endif %}
{% if student_cards_module_enabled %} {% if student_cards_module_enabled %}
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Bibliotheksausweis</a></li> <li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Bibliotheksausweis</a></li>
+6 -10
View File
@@ -28,11 +28,10 @@
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="tab-legal" data-bs-toggle="tab" data-bs-target="#pane-legal" <button class="nav-link" id="tab-legal" data-bs-toggle="tab" data-bs-target="#pane-legal"
type="button" role="tab">Rechtsgrundlage</button> type="button" role="tab">Rechtsgrundlage</button>
</li>
</ul> </ul>
<!-- ── NOTICE ────────────────────────────────────────────────────── --> <!-- ── NOTICE ────────────────────────────────────────────────────── -->
<div class="tab-pane fade" id="pane-notice" role="tabpanel"> <div class="tab-pane" id="pane-notice" role="tabpanel">
<div class="license-content"> <div class="license-content">
<h2>NOTICE Urheberrechtliche Hinweise</h2> <h2>NOTICE Urheberrechtliche Hinweise</h2>
<p><strong>Invario Modul Suite</strong><br>Copyright © 2026 Invario UG</p> <p><strong>Invario Modul Suite</strong><br>Copyright © 2026 Invario UG</p>
@@ -59,14 +58,14 @@
<h4>Frontend / Laufzeit-Assets</h4> <h4>Frontend / Laufzeit-Assets</h4>
<ul> <ul>
<li>html5-qrcode (MIT) © 2020-2025 Manoj Brahmbhatt (mebjas)</li> <li>quagga2 (MIT) © 2014 Christoph Oberhofer, © 2019 Eric Blade and contributors</li>
</ul> </ul>
<hr> <hr>
</div> </div>
</div><!-- /#pane-notice --> </div><!-- /#pane-notice -->
<!-- ── DATENSCHUTZ ───────────────────────────────────────────────── --> <!-- ── DATENSCHUTZ ───────────────────────────────────────────────── -->
<div class="tab-pane fade" id="pane-privacy" role="tabpanel"> <div class="tab-pane" id="pane-privacy" role="tabpanel">
<div class="license-content"> <div class="license-content">
<h2>Datenschutzerklärung (Privacy Policy)</h2> <h2>Datenschutzerklärung (Privacy Policy)</h2>
@@ -108,7 +107,7 @@
</div><!-- /#pane-privacy --> </div><!-- /#pane-privacy -->
<!-- ── SICHERHEIT ────────────────────────────────────────────────── --> <!-- ── SICHERHEIT ────────────────────────────────────────────────── -->
<div class="tab-pane fade" id="pane-security" role="tabpanel"> <div class="tab-pane" id="pane-security" role="tabpanel">
<div class="license-content"> <div class="license-content">
<h2>Sicherheitsrichtlinie (Security Policy)</h2> <h2>Sicherheitsrichtlinie (Security Policy)</h2>
@@ -129,7 +128,7 @@
</div><!-- /#pane-security --> </div><!-- /#pane-security -->
<!-- ── DATENVERARBEITUNG ─────────────────────────────────────────── --> <!-- ── DATENVERARBEITUNG ─────────────────────────────────────────── -->
<div class="tab-pane fade" id="pane-data" role="tabpanel"> <div class="tab-pane" id="pane-data" role="tabpanel">
<div class="license-content"> <div class="license-content">
<h2>Dokumentation der Datenverarbeitung (VVT)</h2> <h2>Dokumentation der Datenverarbeitung (VVT)</h2>
@@ -157,7 +156,7 @@
</div><!-- /#pane-data --> </div><!-- /#pane-data -->
<!-- ── RECHTSGRUNDLAGE ───────────────────────────────────────────── --> <!-- ── RECHTSGRUNDLAGE ───────────────────────────────────────────── -->
<div class="tab-pane fade" id="pane-legal" role="tabpanel"> <div class="tab-pane" id="pane-legal" role="tabpanel">
<div class="license-content"> <div class="license-content">
<h2>Rechtsgrundlage der Nutzung</h2> <h2>Rechtsgrundlage der Nutzung</h2>
<p>Um das Inventarsystem DSGVO-konform zu betreiben, muss der Betreiber eine der folgenden Grundlagen festlegen:</p> <p>Um das Inventarsystem DSGVO-konform zu betreiben, muss der Betreiber eine der folgenden Grundlagen festlegen:</p>
@@ -170,9 +169,6 @@
Einwilligung vorliegen (Art. 6 Abs. 1 lit. a DSGVO).</li> Einwilligung vorliegen (Art. 6 Abs. 1 lit. a DSGVO).</li>
</ol> </ol>
</div> </div>
</div><!-- /#pane-legal -->
</div><!-- /.tab-content -->
</div> </div>
</div> </div>
+1 -1
View File
@@ -43,7 +43,7 @@
<div class="card border-0 shadow-lg rounded-4 h-100"> <div class="card border-0 shadow-lg rounded-4 h-100">
<div class="card-body p-4 p-md-5"> <div class="card-body p-4 p-md-5">
<p class="text-uppercase text-muted fw-semibold mb-2">Terminplaner</p> <p class="text-uppercase text-muted fw-semibold mb-2">Terminplaner</p>
<h1 class="h3 fw-bold mb-3">Termin buchen</h1> <h1 class="h3 fw-bold mb-3">Termin buchen - {{ available.title }}</h1>
<p class="text-muted mb-4">Wählen Sie im Kalender einen freien Slot aus. Den gewählten Termin können Sie danach direkt wie in einem Kalender-Block verschieben.</p> <p class="text-muted mb-4">Wählen Sie im Kalender einen freien Slot aus. Den gewählten Termin können Sie danach direkt wie in einem Kalender-Block verschieben.</p>
<div class="p-3 rounded-3 bg-light mb-3"> <div class="p-3 rounded-3 bg-light mb-3">
+160 -47
View File
@@ -14,15 +14,17 @@
<div class="card-body p-4 p-md-5 bg-white"> <div class="card-body p-4 p-md-5 bg-white">
<form method="post" action="{{ url_for('terminplaner.configure') }}" class="vstack gap-3"> <form method="post" action="{{ url_for('terminplaner.configure') }}" class="vstack gap-3">
<div class="row g-3"> <div class="row g-3">
<labal for="title" class="form-label fw-semibold">Titel des Terminplans</label> <div class="col-12">
<input type="text" id="title" name="title" class="form-control form-control-lg" placeholder="z.B. Elternsprechtag Klasse 10a" value="{{ title or '' }}" required> <label for="title" class="form-label fw-semibold">Titel des Terminplans</label>
<input type="text" id="title" name="title" class="form-control form-control-lg" placeholder="z.B. Elternsprechtag Klasse 10a" value="{{ title or '' }}">
</div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<label for="start_date" class="form-label fw-semibold">Startdatum</label> <label for="start_date" class="form-label fw-semibold">Startdatum</label>
<input type="date" id="start_date" name="start_date" class="form-control form-control-lg" required> <input type="date" id="start_date" name="start_date" class="form-control form-control-lg">
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<label for="end_date" class="form-label fw-semibold">Enddatum</label> <label for="end_date" class="form-label fw-semibold">Enddatum</label>
<input type="date" id="end_date" name="end_date" class="form-control form-control-lg" required> <input type="date" id="end_date" name="end_date" class="form-control form-control-lg">
</div> </div>
</div> </div>
@@ -30,39 +32,49 @@
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-2"> <div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-2">
<div> <div>
<label class="form-label fw-semibold mb-0">Zeitfenster pro Tag</label> <label class="form-label fw-semibold mb-0">Zeitfenster pro Tag</label>
<div class="form-text mb-0">Sobald Start- und Enddatum gesetzt sind, wird für jeden Tag automatisch ein eigener Eintrag erzeugt.</div> <div class="form-text mb-0">Die Start-, End- und Pausenzeiten werden auf alle erzeugten Tage angewandt.</div>
</div> </div>
<button type="button" class="btn btn-outline-primary btn-sm" id="build_time_frame">Tage aus Zeitraum erzeugen</button> <button type="button" class="btn btn-outline-primary btn-sm" id="build_time_frame">Tage aus Zeitraum erzeugen</button>
</div> </div>
<div class="row g-3 mb-3"> <div class="row g-3 mb-3">
<div class="col-12 col-md-6 col-xl-4"> <div class="col-12 col-sm-6 col-lg-3">
<label for="default_day_start" class="form-label fw-semibold">Standard-Startzeit</label> <label for="default_day_start" class="form-label fw-semibold">Standard-Start</label>
<input type="time" id="default_day_start" class="form-control" value="08:00"> <input type="time" id="default_day_start" class="form-control" value="08:00">
</div> </div>
<div class="col-12 col-md-6 col-xl-4"> <div class="col-12 col-sm-6 col-lg-3">
<label for="default_day_end" class="form-label fw-semibold">Standard-Endzeit</label> <label for="default_day_end" class="form-label fw-semibold">Standard-Ende</label>
<input type="time" id="default_day_end" class="form-control" value="12:00"> <input type="time" id="default_day_end" class="form-control" value="16:00">
</div>
<div class="col-12 col-sm-6 col-lg-3">
<label for="default_pause_start" class="form-label fw-semibold text-danger">Pause von</label>
<input type="time" id="default_pause_start" class="form-control border-danger-subtle" value="12:00">
</div>
<div class="col-12 col-sm-6 col-lg-3">
<label for="default_pause_end" class="form-label fw-semibold text-danger">Pause bis</label>
<input type="time" id="default_pause_end" class="form-control border-danger-subtle" value="12:45">
</div> </div>
</div> </div>
<div id="time_frame_days" class="vstack gap-2"></div> <div id="time_frame_days" class="vstack gap-2"></div>
<div class="mt-3"> <div class="mt-3">
<label for="time_frame" class="form-label fw-semibold">Gespeichertes Zeitfenster</label> <label for="time_frame" class="form-label fw-semibold">Gespeichertes Zeitfenster (Übertragung ans Backend)</label>
<textarea id="time_frame" name="time_frame" class="form-control font-monospace" rows="5" placeholder="Wird automatisch aus den Tagen erzeugt" required></textarea> <textarea id="time_frame" name="time_frame" class="form-control font-monospace" rows="5" placeholder="Wird automatisch aus den Tagen erzeugt" readonly></textarea>
<div class="form-text">Das Formular überträgt die erzeugten Tageszeilen an das Backend. Sie können die Liste hier bei Bedarf noch anpassen.</div> <div class="form-text">Pausen teilen den Tag automatisch in zwei buchbare Blöcke, sodass die Pause sicher blockiert ist.</div>
</div> </div>
</div> </div>
<div class="row g-3"> <div class="row g-3">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<label for="slots_amounts" class="form-label fw-semibold">Anzahl Slots</label> <label for="slot_length" class="form-label fw-semibold">Slot-Länge in Minuten</label>
<input type="number" id="slots_amounts" name="slots_amounts" class="form-control" min="1" value="1" required> <input type="number" id="slot_length" name="slot_length" class="form-control form-control-lg" min="1" value="45">
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<label for="slot_lenght" class="form-label fw-semibold">Slot-Länge in Minuten</label> <label class="form-label fw-semibold text-primary">Automatisch berechnete Slots</label>
<input type="number" id="slot_lenght" name="slot_lenght" class="form-control" min="1" value="45" required> <div id="slots_amounts_display" class="form-control form-control-lg bg-primary-subtle text-primary fw-bold d-flex align-items-center">0</div>
<input type="hidden" id="slots_amounts" name="slots_amounts" value="0">
<div class="form-text">Wird aus der gebuchten Zeit (abzüglich Pausen) und der Slot-Länge berechnet.</div>
</div> </div>
</div> </div>
@@ -76,7 +88,6 @@
<label for="note" class="form-label fw-semibold">Notiz</label> <label for="note" class="form-label fw-semibold">Notiz</label>
<textarea id="note" name="note" class="form-control" rows="4" placeholder="Optionaler Einführungstext für die Mail"></textarea> <textarea id="note" name="note" class="form-control" rows="4" placeholder="Optionaler Einführungstext für die Mail"></textarea>
</div> </div>
{% endif %} {% endif %}
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="add_to_calendar" name="add_to_calendar" {% if add_to_calendar %}checked{% endif %}> <input class="form-check-input" type="checkbox" id="add_to_calendar" name="add_to_calendar" {% if add_to_calendar %}checked{% endif %}>
@@ -96,7 +107,7 @@
<div class="alert alert-success mt-4 shadow-sm rounded-4"> <div class="alert alert-success mt-4 shadow-sm rounded-4">
<div class="fw-bold mb-1">Buchungslink erstellt</div> <div class="fw-bold mb-1">Buchungslink erstellt</div>
<div class="mb-2"> <div class="mb-2">
{% if email_service_enabled %} {% if mail_service_enabled %}
Teilen Sie diesen Link mit den Teilnehmenden oder versenden Sie ihn direkt per E-Mail. Teilen Sie diesen Link mit den Teilnehmenden oder versenden Sie ihn direkt per E-Mail.
{% else %} {% else %}
Der E-Mail-Service ist deaktiviert. Teilen Sie diesen Link manuell mit den Teilnehmenden. Der E-Mail-Service ist deaktiviert. Teilen Sie diesen Link manuell mit den Teilnehmenden.
@@ -121,30 +132,31 @@
const buildButton = document.getElementById('build_time_frame'); const buildButton = document.getElementById('build_time_frame');
const daysContainer = document.getElementById('time_frame_days'); const daysContainer = document.getElementById('time_frame_days');
const timeFrameTextarea = document.getElementById('time_frame'); const timeFrameTextarea = document.getElementById('time_frame');
const defaultStartInput = document.getElementById('default_day_start'); const defaultStartInput = document.getElementById('default_day_start');
const defaultEndInput = document.getElementById('default_day_end'); const defaultEndInput = document.getElementById('default_day_end');
const defaultPauseStartInput = document.getElementById('default_pause_start');
const defaultPauseEndInput = document.getElementById('default_pause_end');
if (!startDateInput || !endDateInput || !buildButton || !daysContainer || !timeFrameTextarea || !defaultStartInput || !defaultEndInput) { const slotLengthInput = document.getElementById('slot_length');
const slotsAmountsInput = document.getElementById('slots_amounts');
const slotsAmountsDisplay = document.getElementById('slots_amounts_display'); // Neu: Anzeige-Element
if (!startDateInput || !endDateInput || !buildButton || !daysContainer || !timeFrameTextarea) {
return; return;
} }
const weekdayFormatter = new Intl.DateTimeFormat('de-DE', { const weekdayFormatter = new Intl.DateTimeFormat('de-DE', {
weekday: 'long', weekday: 'short',
day: '2-digit', day: '2-digit',
month: '2-digit', month: '2-digit',
year: 'numeric', year: 'numeric',
}); });
function parseDate(value) { function parseDate(value) {
if (!value) { if (!value) return null;
return null;
}
const parts = value.split('-').map(Number); const parts = value.split('-').map(Number);
if (parts.length !== 3 || parts.some(Number.isNaN)) { if (parts.length !== 3 || parts.some(Number.isNaN)) return null;
return null;
}
return new Date(parts[0], parts[1] - 1, parts[2]); return new Date(parts[0], parts[1] - 1, parts[2]);
} }
@@ -165,16 +177,92 @@
return copy; return copy;
} }
function timeToMinutes(timeStr) {
if (!timeStr) return 0;
const parts = timeStr.split(':').map(Number);
return parts[0] * 60 + parts[1];
}
function updateSlotsDisplay(totalSlots) {
if (slotsAmountsInput) slotsAmountsInput.value = totalSlots;
if (slotsAmountsDisplay) slotsAmountsDisplay.innerText = totalSlots + " Slots gesamt";
}
// Berechnet die Slots basierend auf den konfigurierten Zeiten & Pausen
function calculateSlots() {
if (!slotLengthInput || !slotsAmountsInput) return;
const slotLength = parseInt(slotLengthInput.value, 10);
if (isNaN(slotLength) || slotLength <= 0) {
updateSlotsDisplay(0);
return;
}
let totalSlots = 0;
const rows = Array.from(daysContainer.querySelectorAll('[data-day-row]'));
rows.forEach(row => {
const startTime = row.querySelector('[data-time-start]')?.value;
const endTime = row.querySelector('[data-time-end]')?.value;
const pauseStart = row.querySelector('[data-time-pause-start]')?.value;
const pauseEnd = row.querySelector('[data-time-pause-end]')?.value;
if (!startTime || !endTime) return;
const startMins = timeToMinutes(startTime);
const endMins = timeToMinutes(endTime);
const pauseStartMins = timeToMinutes(pauseStart);
const pauseEndMins = timeToMinutes(pauseEnd);
if (endMins <= startMins) return; // Ungültige Zeit
// Wenn eine gültige Pause innerhalb der Start/Endzeit existiert
if (pauseStartMins > 0 && pauseEndMins > 0 && pauseStartMins < pauseEndMins && pauseStartMins > startMins && pauseStartMins < endMins) {
// Segment 1 (Vor der Pause)
const segment1 = pauseStartMins - startMins;
totalSlots += Math.floor(segment1 / slotLength);
// Segment 2 (Nach der Pause)
const effectivePauseEnd = Math.min(pauseEndMins, endMins);
const segment2 = endMins - effectivePauseEnd;
if (segment2 > 0) {
totalSlots += Math.floor(segment2 / slotLength);
}
} else {
// Ohne Pause oder ungültige Pause -> Komplett durchrechnen
totalSlots += Math.floor((endMins - startMins) / slotLength);
}
});
updateSlotsDisplay(totalSlots);
}
// Synchronisiert das Textfeld und fügt den Break-Cut hinzu
function syncTextarea() { function syncTextarea() {
const rows = Array.from(daysContainer.querySelectorAll('[data-day-row]')); const rows = Array.from(daysContainer.querySelectorAll('[data-day-row]'));
const lines = rows.map(function (row) { const lines = [];
rows.forEach(function (row) {
const dayValue = row.getAttribute('data-day-value') || ''; const dayValue = row.getAttribute('data-day-value') || '';
const startTime = row.querySelector('[data-time-start]')?.value || ''; const startTime = row.querySelector('[data-time-start]')?.value || '';
const endTime = row.querySelector('[data-time-end]')?.value || ''; const endTime = row.querySelector('[data-time-end]')?.value || '';
return `${dayValue} ${startTime}-${endTime}`.trim(); const pauseStart = row.querySelector('[data-time-pause-start]')?.value || '';
}).filter(Boolean); const pauseEnd = row.querySelector('[data-time-pause-end]')?.value || '';
if (!dayValue || !startTime || !endTime) return;
// Hat der Nutzer eine Pause eingetragen, die innerhalb des Tagesfensters liegt?
if (pauseStart && pauseEnd && pauseStart < pauseEnd && pauseStart > startTime && pauseStart < endTime) {
// Zeile am Pausenbeginn aufsplitten -> 2 Blöcke = automatische Pause
lines.push(`${dayValue} ${startTime}-${pauseStart}`);
lines.push(`${dayValue} ${pauseEnd}-${endTime}`);
} else {
lines.push(`${dayValue} ${startTime}-${endTime}`);
}
});
timeFrameTextarea.value = lines.join('\n'); timeFrameTextarea.value = lines.join('\n');
calculateSlots(); // Nach jedem Update neu berechnen
} }
function renderRows() { function renderRows() {
@@ -197,40 +285,57 @@
endDateInput.setCustomValidity(''); endDateInput.setCustomValidity('');
// Bisherige Werte sichern
const existingValues = new Map(); const existingValues = new Map();
Array.from(daysContainer.querySelectorAll('[data-day-row]')).forEach(function (row) { Array.from(daysContainer.querySelectorAll('[data-day-row]')).forEach(function (row) {
const dayValue = row.getAttribute('data-day-value'); const dayValue = row.getAttribute('data-day-value');
const startTime = row.querySelector('[data-time-start]')?.value || ''; const startTime = row.querySelector('[data-time-start]')?.value || '';
const endTime = row.querySelector('[data-time-end]')?.value || ''; const endTime = row.querySelector('[data-time-end]')?.value || '';
const pauseStart = row.querySelector('[data-time-pause-start]')?.value || '';
const pauseEnd = row.querySelector('[data-time-pause-end]')?.value || '';
if (dayValue) { if (dayValue) {
existingValues.set(dayValue, { startTime, endTime }); existingValues.set(dayValue, { startTime, endTime, pauseStart, pauseEnd });
} }
}); });
const defaultStart = defaultStartInput.value || '08:00'; const defaultStart = defaultStartInput.value || '08:00';
const defaultEnd = defaultEndInput.value || '12:00'; const defaultEnd = defaultEndInput.value || '16:00';
const defaultPauseStart = defaultPauseStartInput.value || '';
const defaultPauseEnd = defaultPauseEndInput.value || '';
const rows = []; const rows = [];
for (let current = startDate; current <= endDate; current = addDays(current, 1)) { for (let current = startDate; current <= endDate; current = addDays(current, 1)) {
const dayValue = formatDateForValue(current); const dayValue = formatDateForValue(current);
const preserved = existingValues.get(dayValue) || {}; const preserved = existingValues.get(dayValue) || {};
const rowStart = preserved.startTime || defaultStart;
const rowEnd = preserved.endTime || defaultEnd; const rowStart = preserved.startTime !== undefined ? preserved.startTime : defaultStart;
const rowEnd = preserved.endTime !== undefined ? preserved.endTime : defaultEnd;
const rowPauseStart = preserved.pauseStart !== undefined ? preserved.pauseStart : defaultPauseStart;
const rowPauseEnd = preserved.pauseEnd !== undefined ? preserved.pauseEnd : defaultPauseEnd;
rows.push(` rows.push(`
<div class="border rounded-3 bg-white p-3" data-day-row data-day-value="${dayValue}"> <div class="border rounded-3 bg-white p-3" data-day-row data-day-value="${dayValue}">
<div class="row g-2 align-items-end"> <div class="row g-2 align-items-end">
<div class="col-12 col-lg-5"> <div class="col-12 col-lg-4">
<label class="form-label fw-semibold mb-1">${formatDateForRow(current)}</label> <label class="form-label fw-semibold mb-1">${formatDateForRow(current)}</label>
<div class="text-muted small">${dayValue}</div> <div class="text-muted small">${dayValue}</div>
</div> </div>
<div class="col-6 col-lg-3"> <div class="col-6 col-lg-2">
<label class="form-label mb-1">Von</label> <label class="form-label mb-1 text-muted small">Von</label>
<input type="time" class="form-control" value="${rowStart}" data-time-start> <input type="time" class="form-control form-control-sm" value="${rowStart}" data-time-start>
</div> </div>
<div class="col-6 col-lg-3"> <div class="col-6 col-lg-2">
<label class="form-label mb-1">Bis</label> <label class="form-label mb-1 text-muted small">Bis</label>
<input type="time" class="form-control" value="${rowEnd}" data-time-end> <input type="time" class="form-control form-control-sm" value="${rowEnd}" data-time-end>
</div>
<div class="col-6 col-lg-2">
<label class="form-label mb-1 text-danger small">Pause ab</label>
<input type="time" class="form-control form-control-sm border-danger-subtle" value="${rowPauseStart}" data-time-pause-start>
</div>
<div class="col-6 col-lg-2">
<label class="form-label mb-1 text-danger small">Pause bis</label>
<input type="time" class="form-control form-control-sm border-danger-subtle" value="${rowPauseEnd}" data-time-pause-end>
</div> </div>
</div> </div>
</div> </div>
@@ -246,13 +351,21 @@
syncTextarea(); syncTextarea();
} }
// Event Listeners Registration
buildButton.addEventListener('click', renderRows); buildButton.addEventListener('click', renderRows);
startDateInput.addEventListener('input', renderRows);
startDateInput.addEventListener('change', renderRows); startDateInput.addEventListener('change', renderRows);
endDateInput.addEventListener('input', renderRows);
endDateInput.addEventListener('change', renderRows); endDateInput.addEventListener('change', renderRows);
defaultStartInput.addEventListener('change', syncTextarea);
defaultEndInput.addEventListener('change', syncTextarea); defaultStartInput.addEventListener('change', renderRows);
defaultEndInput.addEventListener('change', renderRows);
defaultPauseStartInput.addEventListener('change', renderRows);
defaultPauseEndInput.addEventListener('change', renderRows);
// Slot Berechnung auf Ändeurng der Länge antriggern
if(slotLengthInput) {
slotLengthInput.addEventListener('input', calculateSlots);
slotLengthInput.addEventListener('change', calculateSlots);
}
if (startDateInput.value && endDateInput.value) { if (startDateInput.value && endDateInput.value) {
renderRows(); renderRows();
+22 -2
View File
@@ -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);
+1
View File
@@ -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
+105 -133
View File
@@ -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"
@@ -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
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." 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'
+1
View File
@@ -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