Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0c61a41a3 | |||
| 9e74d0c16d | |||
| c8818edf0b | |||
| b270bc9367 | |||
| e1c284fc40 | |||
| debd4dceb1 | |||
| db15df57c3 | |||
| bcfe2095d5 | |||
| 69ac6eca63 | |||
| 717e3ce15e | |||
| 007ae04bf3 | |||
| c0947c5cf1 | |||
| c1d2935ad6 | |||
| 9fde0b4b2a | |||
| da7b075cc4 | |||
| 347f258b8f | |||
| 2421f867ed | |||
| 921915e92f | |||
| a4b0866293 | |||
| e10d86da4b | |||
| f160f23e9c | |||
| ca272542de | |||
| 7df9b65389 | |||
| e4af76c9c1 | |||
| f5944bf090 | |||
| 4e68da2368 | |||
| 0d6aca4e8c | |||
| 2bb0cbe599 | |||
| 339487b4d4 | |||
| 3f90e2d4f1 | |||
| b238b52fbf | |||
| 545dd7783c | |||
| f4ce1cf3b4 | |||
| 7bd12bddaa | |||
| b8e23b94d2 | |||
| 06b32b4a3d | |||
| d24bd6ba56 | |||
| d1f48cf184 | |||
| 32fccd17ec | |||
| 01447220a0 | |||
| b3aeb289ef | |||
| a6b90a6a9d | |||
| 0a5d7dedea | |||
| 74e11b5370 | |||
| c0d6ad7b72 | |||
| 7da7152332 | |||
| 3c9ab099ba | |||
| a81a8775a5 | |||
| 1633118d7f | |||
| a4b900c986 | |||
| a06393a915 | |||
| 1b9bfe1b14 | |||
| 3cdd1552d9 | |||
| 80a206f524 | |||
| 3217da8b15 | |||
| 22bc7c9c3c | |||
| f156cc178a | |||
| 116261d334 | |||
| 042afc19e2 | |||
| 28d049b52a |
+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
|
||||
|
||||
+398
-142
@@ -25,6 +25,7 @@ from werkzeug.routing import BuildError
|
||||
from jinja2 import TemplateNotFound
|
||||
import os
|
||||
import sys
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# Ensure imports work regardless of whether gunicorn starts in /app or /app/Web.
|
||||
_CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -730,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
|
||||
|
||||
@@ -1268,7 +1270,8 @@ def inject_version():
|
||||
'inventory_module_enabled': cfg.MODULES.is_enabled('inventory'),
|
||||
'terminplan_module_enabled': cfg.MODULES.is_enabled('terminplan'),
|
||||
'library_module_enabled': cfg.MODULES.is_enabled('library'),
|
||||
'student_cards_module_enabled': cfg.MODULES.is_enabled('student_cards'),
|
||||
'student_cards_module_enabled': cfg.MODULES.is_enabled('student_cards'),#
|
||||
'mail_module_enabled': cfg.MODULES.is_enabled('mail'),
|
||||
'is_admin': is_admin,
|
||||
'unread_notification_count': unread_notification_count,
|
||||
'current_permissions': current_permissions,
|
||||
@@ -1734,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)
|
||||
@@ -1994,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'],
|
||||
@@ -2885,6 +2887,7 @@ def home():
|
||||
username=session['username'],
|
||||
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
||||
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
||||
mail_module_enabled=cfg.MODULES.is_enabled('mail'),
|
||||
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
|
||||
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS,
|
||||
open_item=request.args.get('open_item')
|
||||
@@ -2927,6 +2930,7 @@ def home_admin():
|
||||
username=session['username'],
|
||||
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
||||
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
||||
mail_module_enabled=cfg.MODULES.is_enabled('mail'),
|
||||
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
|
||||
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS,
|
||||
school_info=_get_school_info_for_export(),
|
||||
@@ -2947,6 +2951,7 @@ def tutorial_page():
|
||||
is_admin=us.check_admin(session['username']),
|
||||
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
||||
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
||||
mail_module_enabled=cfg.MODULES.is_enabled('mail'),
|
||||
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
|
||||
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS
|
||||
)
|
||||
@@ -3816,7 +3821,7 @@ def library_admin():
|
||||
show_library_features=True,
|
||||
upload_mode='library',
|
||||
page_title='Bücher hochladen',
|
||||
back_target='home_admin'
|
||||
back_target='library'
|
||||
)
|
||||
|
||||
|
||||
@@ -4119,10 +4124,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)
|
||||
@@ -4146,7 +4154,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)
|
||||
@@ -4286,10 +4298,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)
|
||||
@@ -5060,7 +5074,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:
|
||||
@@ -5073,7 +5087,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', '')
|
||||
@@ -5102,6 +5121,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
|
||||
@@ -5181,6 +5201,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'):
|
||||
@@ -5207,55 +5229,87 @@ def upload_item():
|
||||
flash(error_msg, 'error')
|
||||
return redirect(url_for(success_redirect_endpoint))
|
||||
|
||||
# Check if base code is unique for single-item uploads
|
||||
if code_4 and item_count == 1 and not it.is_code_unique(code_4[0]):
|
||||
existing_item = it._get_items_collection().find_one({"code_4": code_4[0]})
|
||||
if existing_item:
|
||||
error_msg = 'Der Code wird bereits verwendet. Umleitung zum Eintrag.'
|
||||
if is_mobile:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': error_msg,
|
||||
'existing_item_id': str(existing_item['_id']),
|
||||
'redirect_to_item': True
|
||||
}), 400
|
||||
|
||||
flash(error_msg, 'info')
|
||||
return redirect(url_for(success_redirect_endpoint, open_item=str(existing_item['_id'])))
|
||||
# -------------------------------------------------------------------
|
||||
# 1. PARSE AND UNIFY CODES
|
||||
# -------------------------------------------------------------------
|
||||
# Assuming `code_4` might come in as a single string or a list depending on your form setup
|
||||
primary_code_raw = request.form.get('code_4', '').strip()
|
||||
individual_codes_raw = request.form.get('individual_codes', '').strip()
|
||||
|
||||
all_item_codes = []
|
||||
|
||||
if primary_code_raw:
|
||||
all_item_codes.append(primary_code_raw)
|
||||
|
||||
if individual_codes_raw:
|
||||
# Split textarea by newlines, clean whitespace, and ignore empty lines
|
||||
extra_codes = [c.strip() for c in individual_codes_raw.replace('\r', '').split('\n') if c.strip()]
|
||||
|
||||
# Add to master list, ensuring no duplicates within the submitted list itself
|
||||
for c in extra_codes:
|
||||
if c not in all_item_codes:
|
||||
all_item_codes.append(c)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 2. OVERRIDE ITEM COUNT
|
||||
# -------------------------------------------------------------------
|
||||
# If the user scanned/generated codes, the amount of codes IS the item count.
|
||||
# Otherwise, fallback to a manual item_count field if it exists.
|
||||
if len(all_item_codes) > 0:
|
||||
item_count = len(all_item_codes)
|
||||
else:
|
||||
try:
|
||||
item_count = int(request.form.get('item_count', 1))
|
||||
except ValueError:
|
||||
item_count = 1
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 3. IMAGE VALIDATION
|
||||
# -------------------------------------------------------------------
|
||||
if upload_mode != 'library' and not is_duplicating and not images and not duplicate_images and not book_cover_image:
|
||||
error_msg = 'Bitte laden Sie mindestens ein Bild hoch'
|
||||
if is_mobile:
|
||||
return jsonify({'success': False, 'message': error_msg}), 400
|
||||
else:
|
||||
error_msg = 'Der Code wird bereits verwendet. Bitte wählen Sie einen anderen Code.'
|
||||
flash(error_msg, 'error')
|
||||
return redirect(url_for(success_redirect_endpoint))
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 4. UNIFIED DB UNIQUENESS VALIDATION
|
||||
# -------------------------------------------------------------------
|
||||
# Validate every code in our master list against the database
|
||||
for code in all_item_codes:
|
||||
if not it.is_code_unique(code):
|
||||
|
||||
# Special case: If they only uploaded ONE item, redirect them to that existing item
|
||||
if item_count == 1:
|
||||
existing_item = it._get_items_collection().find_one({"code_4": code})
|
||||
if existing_item:
|
||||
error_msg = 'Der Code wird bereits verwendet. Umleitung zum Eintrag.'
|
||||
if is_mobile:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': error_msg,
|
||||
'existing_item_id': str(existing_item['_id']),
|
||||
'redirect_to_item': True
|
||||
}), 400
|
||||
|
||||
flash(error_msg, 'info')
|
||||
return redirect(url_for(success_redirect_endpoint, open_item=str(existing_item['_id'])))
|
||||
|
||||
# If multiple items are being uploaded, just throw a standard error
|
||||
error_msg = f'Der Code "{code}" wird bereits von einem anderen Artikel verwendet. Bitte überprüfen Sie Ihre Eingaben.'
|
||||
if is_mobile:
|
||||
return jsonify({'success': False, 'message': error_msg}), 400
|
||||
else:
|
||||
flash(error_msg, 'error')
|
||||
return redirect(url_for(success_redirect_endpoint))
|
||||
|
||||
# Validate optional per-item codes
|
||||
if individual_codes:
|
||||
if len(individual_codes) > item_count:
|
||||
error_msg = f'Zu viele Einzelcodes angegeben ({len(individual_codes)}), erlaubt sind maximal {item_count}.'
|
||||
if is_mobile:
|
||||
return jsonify({'success': False, 'message': error_msg}), 400
|
||||
flash(error_msg, 'error')
|
||||
return redirect(url_for(success_redirect_endpoint))
|
||||
|
||||
if len(set(individual_codes)) != len(individual_codes):
|
||||
error_msg = 'Doppelte Einzelcodes erkannt. Bitte alle Codes eindeutig eintragen.'
|
||||
if is_mobile:
|
||||
return jsonify({'success': False, 'message': error_msg}), 400
|
||||
flash(error_msg, 'error')
|
||||
return redirect(url_for(success_redirect_endpoint))
|
||||
|
||||
for specific_code in individual_codes:
|
||||
if not it.is_code_unique(specific_code):
|
||||
error_msg = f'Der Einzelcode "{specific_code}" wird bereits verwendet.'
|
||||
if is_mobile:
|
||||
return jsonify({'success': False, 'message': error_msg}), 400
|
||||
flash(error_msg, 'error')
|
||||
return redirect(url_for(success_redirect_endpoint))
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 5. BATCH CODE GENERATOR (Remains mostly unchanged)
|
||||
# -------------------------------------------------------------------
|
||||
def generate_unique_batch_code(base_code, position):
|
||||
"""Generate a unique code for every item in a batch."""
|
||||
"""Generate a unique code for every item in a batch if no specific code is provided."""
|
||||
if not base_code:
|
||||
return None
|
||||
|
||||
@@ -5995,22 +6049,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:
|
||||
@@ -7948,7 +7989,8 @@ def admin_borrowings():
|
||||
'admin_borrowings.html',
|
||||
entries=entries,
|
||||
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
||||
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards')
|
||||
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
||||
mail_module_enabled=cfg.MODULES.is_enabled('mail')
|
||||
)
|
||||
|
||||
"""-----------------------------------------------------------Audit Routes-------------------------------------------------------"""
|
||||
@@ -8011,6 +8053,7 @@ def admin_audit_dashboard():
|
||||
audit_rows=audit_rows,
|
||||
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
||||
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
||||
mail_module_enabled=cfg.MODULES.is_enabled('mail'),
|
||||
)
|
||||
except Exception as exc:
|
||||
app.logger.error(f"Error loading audit dashboard: {exc}")
|
||||
@@ -8826,6 +8869,7 @@ def library_item_invoices(item_id):
|
||||
invoices=entries,
|
||||
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
||||
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
||||
mail_module_enabled=cfg.MODULES.is_enabled('mail')
|
||||
)
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error loading invoice history for item {item_id}: {e}")
|
||||
@@ -9376,17 +9420,265 @@ def search_word(word):
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "response": str(e)})
|
||||
|
||||
def _fetch_from_google_books(clean_isbn):
|
||||
"""Source 1: Google Books API (Free, No Key required for basic use)"""
|
||||
try:
|
||||
url = f"https://www.googleapis.com/books/v1/volumes?q=isbn:{clean_isbn}"
|
||||
response = requests.get(url, timeout=5)
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
|
||||
data = response.json()
|
||||
if data.get('totalItems', 0) > 0 and data.get('items'):
|
||||
book_info = data['items'][0].get('volumeInfo', {})
|
||||
sale_info = data['items'][0].get('saleInfo', {})
|
||||
|
||||
price = None
|
||||
retail_price = sale_info.get('retailPrice', {})
|
||||
list_price = sale_info.get('listPrice', {})
|
||||
if retail_price and 'amount' in retail_price:
|
||||
price = f"{retail_price['amount']} {retail_price.get('currencyCode', '€')}"
|
||||
elif list_price and 'amount' in list_price:
|
||||
price = f"{list_price['amount']} {list_price.get('currencyCode', '€')}"
|
||||
|
||||
thumbnail = book_info.get('imageLinks', {}).get('thumbnail', '')
|
||||
if thumbnail:
|
||||
thumbnail = thumbnail.replace('http:', 'https:')
|
||||
|
||||
return {
|
||||
"title": book_info.get('title', 'Unknown Title'),
|
||||
"authors": ', '.join(book_info.get('authors', ['Unknown Author'])),
|
||||
"publisher": book_info.get('publisher', 'Unknown Publisher'),
|
||||
"publishedDate": book_info.get('publishedDate', 'Unknown Date'),
|
||||
"description": book_info.get('description', 'Keine Beschreibung verfügbar'),
|
||||
"pageCount": book_info.get('pageCount', 'Unknown'),
|
||||
"price": price,
|
||||
"thumbnail": thumbnail,
|
||||
"source": "google-books"
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Google Books error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_from_lobid_germany(clean_isbn):
|
||||
"""
|
||||
Source 2: Lobid.org (hbz Network)
|
||||
The ultimate free, keyless fallback for German Schoolbooks and DACH literature.
|
||||
"""
|
||||
try:
|
||||
url = f"https://lobid.org/resources?q=isbn:{clean_isbn}&format=json"
|
||||
response = requests.get(url, timeout=5)
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
|
||||
data = response.json()
|
||||
if 'member' in data and len(data['member']) > 0:
|
||||
book = data['member'][0]
|
||||
|
||||
# Extract Authors (Lobid uses a nested contribution array)
|
||||
authors = []
|
||||
for contrib in book.get('contribution', []):
|
||||
if 'agent' in contrib and 'label' in contrib['agent']:
|
||||
authors.append(contrib['agent']['label'])
|
||||
|
||||
# Extract Publishers
|
||||
publishers = []
|
||||
for pub in book.get('publication', []):
|
||||
if 'publishedBy' in pub:
|
||||
# sometimes publishedBy is a list, sometimes a string
|
||||
pub_data = pub['publishedBy']
|
||||
if isinstance(pub_data, list):
|
||||
publishers.extend(pub_data)
|
||||
else:
|
||||
publishers.append(pub_data)
|
||||
|
||||
# Extract Date
|
||||
pub_date = 'Unknown Date'
|
||||
if book.get('publication'):
|
||||
pub_date = book['publication'][0].get('startDate', 'Unknown Date')
|
||||
|
||||
# Extract Pages
|
||||
pages = book.get('extent', ['Unknown'])[0] if 'extent' in book else 'Unknown'
|
||||
|
||||
return {
|
||||
"title": book.get('title', 'Unknown Title'),
|
||||
"authors": ', '.join(authors) if authors else 'Unknown Author',
|
||||
"publisher": ', '.join(publishers) if publishers else 'Unknown Publisher',
|
||||
"publishedDate": str(pub_date),
|
||||
"description": 'Keine Beschreibung verfügbar (Schulbuch / Fachliteratur)',
|
||||
"pageCount": pages,
|
||||
"price": None,
|
||||
"thumbnail": "", # Lobid rarely has covers, we will fallback to OpenLibrary cover below
|
||||
"source": "lobid-germany"
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Lobid error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_from_open_library(clean_isbn):
|
||||
"""Source 3: Open Library Search API (Free, No Key required)"""
|
||||
try:
|
||||
url = f"https://openlibrary.org/search.json?isbn={clean_isbn}"
|
||||
response = requests.get(url, timeout=5)
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
|
||||
data = response.json()
|
||||
if data.get('numFound', 0) > 0 and data.get('docs'):
|
||||
book_info = data['docs'][0]
|
||||
|
||||
return {
|
||||
"title": book_info.get('title', 'Unknown Title'),
|
||||
"authors": ', '.join(book_info.get('author_name', ['Unknown Author'])),
|
||||
"publisher": ', '.join(book_info.get('publisher', ['Unknown Publisher'])),
|
||||
"publishedDate": str(book_info.get('publish_date', ['Unknown Date'])[0]),
|
||||
"description": 'Keine Beschreibung verfügbar',
|
||||
"pageCount": book_info.get('number_of_pages_median', 'Unknown'),
|
||||
"price": None,
|
||||
"thumbnail": f"https://covers.openlibrary.org/b/isbn/{clean_isbn}-L.jpg",
|
||||
"source": "openlibrary"
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"OpenLibrary Search error: {e}")
|
||||
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>')
|
||||
def fetch_book_info(isbn):
|
||||
"""
|
||||
API endpoint to fetch book information by ISBN using Google Books API
|
||||
|
||||
Args:
|
||||
isbn (str): ISBN to look up
|
||||
|
||||
Returns:
|
||||
dict: Book information or error message
|
||||
API endpoint to fetch book information by ISBN using multiple open sources.
|
||||
Optimized for global literature AND German educational books.
|
||||
"""
|
||||
# Authorization Checks
|
||||
if 'username' not in session or not us.check_admin(session['username']):
|
||||
return jsonify({"error": "Not authorized"}), 403
|
||||
|
||||
@@ -9394,79 +9686,34 @@ def fetch_book_info(isbn):
|
||||
return jsonify({"error": "Bibliotheks-Modul ist deaktiviert."}), 403
|
||||
|
||||
try:
|
||||
# Validation
|
||||
clean_isbn = normalize_and_validate_isbn(isbn)
|
||||
if not clean_isbn:
|
||||
return jsonify({"error": "Ungültige ISBN. Bitte ISBN-10 oder ISBN-13 verwenden."}), 400
|
||||
|
||||
# First source: Google Books
|
||||
response = requests.get(
|
||||
f"https://www.googleapis.com/books/v1/volumes?q=isbn:{clean_isbn}",
|
||||
timeout=10
|
||||
)
|
||||
# Define the Provider Chain (Order matters: General -> German/School -> Global Fallback)
|
||||
providers = [
|
||||
_fetch_from_google_books,
|
||||
_fetch_from_lobid_germany,
|
||||
_fetch_from_isbn_de,
|
||||
_fetch_from_open_library
|
||||
]
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get('totalItems', 0) > 0 and data.get('items'):
|
||||
book_info = data['items'][0].get('volumeInfo', {})
|
||||
sale_info = data['items'][0].get('saleInfo', {})
|
||||
|
||||
price = None
|
||||
retail_price = sale_info.get('retailPrice', {})
|
||||
list_price = sale_info.get('listPrice', {})
|
||||
if retail_price and 'amount' in retail_price:
|
||||
price = f"{retail_price['amount']} {retail_price.get('currencyCode', '€')}"
|
||||
elif list_price and 'amount' in list_price:
|
||||
price = f"{list_price['amount']} {list_price.get('currencyCode', '€')}"
|
||||
|
||||
thumbnail = book_info.get('imageLinks', {}).get('thumbnail', '')
|
||||
if thumbnail:
|
||||
thumbnail = thumbnail.replace('http:', 'https:')
|
||||
|
||||
return jsonify({
|
||||
"title": book_info.get('title', 'Unknown Title'),
|
||||
"authors": ', '.join(book_info.get('authors', ['Unknown Author'])),
|
||||
"publisher": book_info.get('publisher', 'Unknown Publisher'),
|
||||
"publishedDate": book_info.get('publishedDate', 'Unknown Date'),
|
||||
"description": book_info.get('description', 'No description available'),
|
||||
"pageCount": book_info.get('pageCount', 'Unknown'),
|
||||
"price": price,
|
||||
"thumbnail": thumbnail,
|
||||
"isbn": clean_isbn,
|
||||
"source": "google-books"
|
||||
})
|
||||
|
||||
# Fallback: OpenLibrary
|
||||
ol_response = requests.get(
|
||||
f"https://openlibrary.org/isbn/{clean_isbn}.json",
|
||||
timeout=10
|
||||
)
|
||||
if ol_response.status_code == 200:
|
||||
ol_data = ol_response.json()
|
||||
author_names = []
|
||||
for author_ref in ol_data.get('authors', []):
|
||||
key = author_ref.get('key')
|
||||
if not key:
|
||||
continue
|
||||
try:
|
||||
author_resp = requests.get(f"https://openlibrary.org{key}.json", timeout=8)
|
||||
if author_resp.status_code == 200:
|
||||
author_names.append(author_resp.json().get('name'))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return jsonify({
|
||||
"title": ol_data.get('title', 'Unknown Title'),
|
||||
"authors": ', '.join([a for a in author_names if a]) if author_names else 'Unknown Author',
|
||||
"publisher": ', '.join(ol_data.get('publishers', [])) if ol_data.get('publishers') else 'Unknown Publisher',
|
||||
"publishedDate": ol_data.get('publish_date', 'Unknown Date'),
|
||||
"description": (ol_data.get('description', {}).get('value') if isinstance(ol_data.get('description'), dict) else ol_data.get('description')) or 'No description available',
|
||||
"pageCount": ol_data.get('number_of_pages', 'Unknown'),
|
||||
"price": None,
|
||||
"thumbnail": f"https://covers.openlibrary.org/b/isbn/{clean_isbn}-L.jpg",
|
||||
"isbn": clean_isbn,
|
||||
"source": "openlibrary"
|
||||
})
|
||||
# Iterate through providers until a book is found
|
||||
for provider in providers:
|
||||
book_data = provider(clean_isbn)
|
||||
|
||||
if book_data:
|
||||
# Add the ISBN back into the response payload
|
||||
book_data["isbn"] = clean_isbn
|
||||
|
||||
# If a provider found the book but had no cover, assign a default OpenLibrary cover fallback
|
||||
if not book_data.get("thumbnail"):
|
||||
book_data["thumbnail"] = f"https://covers.openlibrary.org/b/isbn/{clean_isbn}-L.jpg"
|
||||
|
||||
return jsonify(book_data)
|
||||
|
||||
# If all providers fail
|
||||
return jsonify({"error": f"Kein Buch zu dieser ISBN gefunden: {clean_isbn}"}), 404
|
||||
|
||||
except Exception as e:
|
||||
@@ -10017,10 +10264,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)
|
||||
@@ -10086,6 +10341,7 @@ def admin_damaged_items():
|
||||
damaged_items=damaged_rows,
|
||||
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
||||
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
||||
mail_module_enabled=cfg.MODULES.is_enabled('mail')
|
||||
)
|
||||
except Exception as exc:
|
||||
app.logger.error(f"Error loading damaged-items admin view: {exc}")
|
||||
|
||||
@@ -284,7 +284,6 @@ def _match_inventory(path):
|
||||
if path == '/' or path.startswith('/home'): return True
|
||||
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):
|
||||
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:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
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,
|
||||
'mail': mail,
|
||||
'note': note,
|
||||
'title': title,
|
||||
'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
|
||||
'Created': 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):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from modules.module_registry import ModuleRegistry as mr
|
||||
import smtplib
|
||||
|
||||
import Web.modules.database.settings as cfg
|
||||
@@ -27,21 +28,24 @@ def send(email: list, subject: str, note: str, sender: str) -> bool:
|
||||
Output:
|
||||
- bool: true if the sending worked and false if it didnt
|
||||
"""
|
||||
msg = MIMEMultipart()
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = sender or cfg.EMAIL_FROM_ADDRESS or cfg.EMAIL_USERNAME
|
||||
msg['To'] = ', '.join(email) if isinstance(email, (list, tuple)) else str(email)
|
||||
msg.attach(MIMEText(note))
|
||||
smtp = None
|
||||
try:
|
||||
smtp = _build_smtp_client()
|
||||
smtp.sendmail(from_addr=msg['From'], to_addrs=email, msg=msg.as_string())
|
||||
return True
|
||||
except Exception:
|
||||
if not mr.registry.is_enabled('mail'):
|
||||
return False
|
||||
finally:
|
||||
else:
|
||||
msg = MIMEMultipart()
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = sender or cfg.EMAIL_FROM_ADDRESS or cfg.EMAIL_USERNAME
|
||||
msg['To'] = ', '.join(email) if isinstance(email, (list, tuple)) else str(email)
|
||||
msg.attach(MIMEText(note))
|
||||
smtp = None
|
||||
try:
|
||||
if smtp:
|
||||
smtp.quit()
|
||||
smtp = _build_smtp_client()
|
||||
smtp.sendmail(from_addr=msg['From'], to_addrs=email, msg=msg.as_string())
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
if smtp:
|
||||
smtp.quit()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -82,6 +82,7 @@ def build_calendar_ics(appointment_id: str) -> str | None:
|
||||
time_span = item.get('time_span', []) or []
|
||||
creator = item.get('user', 'Terminplaner')
|
||||
note = item.get('note', '') or ''
|
||||
titel = item.get('title', '') or ''
|
||||
tenant_id = _current_tenant_id()
|
||||
try:
|
||||
link = url_for('terminplaner.client', appointment_id=str(appointment_id), tenant=tenant_id or None, _external=True)
|
||||
@@ -108,6 +109,8 @@ def build_calendar_ics(appointment_id: str) -> str | None:
|
||||
description_lines.append('Zeitfenster: ' + '; '.join(str(entry) for entry in time_span))
|
||||
if note:
|
||||
description_lines.append('Notiz: ' + str(note))
|
||||
if titel:
|
||||
description_lines.append('Titel: ' + str(titel))
|
||||
|
||||
ics_lines = [
|
||||
'BEGIN:VCALENDAR',
|
||||
@@ -123,6 +126,7 @@ def build_calendar_ics(appointment_id: str) -> str | None:
|
||||
f'URL:{_escape_ics_text(link)}',
|
||||
f'DTSTART;VALUE=DATE:{_format_ics_date(start_date)}',
|
||||
f'DTEND;VALUE=DATE:{_format_ics_date(end_date + timedelta(days=1))}',
|
||||
f'Titel:{_escape_ics_text(titel)}',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
'',
|
||||
@@ -142,9 +146,10 @@ def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str
|
||||
return None
|
||||
|
||||
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:
|
||||
slot_minutes = 0
|
||||
|
||||
if slot_minutes <= 0:
|
||||
slot_minutes = 45
|
||||
|
||||
@@ -159,7 +164,7 @@ def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str
|
||||
if tenant_id:
|
||||
link += f'?tenant={tenant_id}'
|
||||
|
||||
title_name = str(client_name or '').strip() or 'Termin'
|
||||
title_name = item.get('title') or f"Terminbuchung mit {client_name}" if client_name else "Terminbuchung"
|
||||
summary = f"{title_name} - Terminbuchung"
|
||||
description_lines = [
|
||||
f"Buchungslink: {link}",
|
||||
@@ -185,6 +190,7 @@ def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str
|
||||
f'URL:{_escape_ics_text(link)}',
|
||||
f'DTSTART:{dt_start}',
|
||||
f'DTEND:{dt_end}',
|
||||
f'Titel:{_escape_ics_text(summary)}',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
'',
|
||||
@@ -192,23 +198,24 @@ def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str
|
||||
return '\r\n'.join(ics_lines)
|
||||
|
||||
|
||||
def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False) -> dict:
|
||||
def new(date_start: str, date_end: str, time_span: list, slots, slot_length, user: str, mail: list=None, note:str="", calendar_enabled: bool=False, title: str="") -> dict:
|
||||
"""
|
||||
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_mail = _normalize_mail_list(mail)
|
||||
id = termin.add(date_start, date_end, normalized_time_span, slots, slot_lenght, user, normalized_mail, note, calendar_enabled=calendar_enabled)
|
||||
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_str = str(id)
|
||||
tenant_id = _current_tenant_id()
|
||||
|
||||
@@ -219,9 +226,11 @@ def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
|
||||
link = host + "/terminplaner/client/" + id_str
|
||||
if tenant_id:
|
||||
link += f"?tenant={tenant_id}"
|
||||
subject = f"Terminanfrage von {user}"
|
||||
|
||||
subject = f"{title} - Bitte Termin vereinbaren" if title else f"Terminanfrage von {user} - Bitte Termin vereinbaren"
|
||||
note_link = note + f"Bitte klicken sie auf den folgenden Link um einen Termin zu vereinbaren: {link}"
|
||||
calendar_link = None
|
||||
|
||||
if calendar_enabled:
|
||||
try:
|
||||
calendar_link = url_for('terminplaner.calendar_export', appointment_id=id_str, tenant=tenant_id or None, _external=True)
|
||||
@@ -246,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):
|
||||
"""
|
||||
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:
|
||||
# Retrieve the current appointment
|
||||
item = termin.get_item(id)
|
||||
if not item:
|
||||
return False
|
||||
@@ -276,10 +273,7 @@ def book_slot(id, date_start_time, name):
|
||||
if existing[0] == date_start_time and existing[1] == name:
|
||||
return False
|
||||
|
||||
# Append the new booking as a tuple (start_time, name)
|
||||
slots.append((date_start_time, name))
|
||||
|
||||
# Update the appointment in the database
|
||||
success = termin.update(id, slots)
|
||||
return bool(success)
|
||||
except Exception as e:
|
||||
@@ -288,19 +282,12 @@ def book_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:
|
||||
# Prefer DB-level remove if available
|
||||
if hasattr(termin, 'remove_slot'):
|
||||
removed = termin.remove_slot(id, date_start_time, name)
|
||||
if removed:
|
||||
return True
|
||||
|
||||
# Fallback: fetch, filter, and replace the slot list
|
||||
item = termin.get_item(id)
|
||||
if not item:
|
||||
return False
|
||||
@@ -309,7 +296,6 @@ def remove_slot(id, date_start_time, name):
|
||||
new_slots = []
|
||||
for s in slots:
|
||||
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:
|
||||
continue
|
||||
except Exception:
|
||||
@@ -324,9 +310,6 @@ def remove_slot(id, date_start_time, name):
|
||||
|
||||
|
||||
def remove_appointment(id):
|
||||
"""
|
||||
Remove an entire appointment by id.
|
||||
"""
|
||||
try:
|
||||
return bool(termin.remove(id))
|
||||
except Exception as e:
|
||||
@@ -334,17 +317,6 @@ def remove_appointment(id):
|
||||
return False
|
||||
|
||||
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:
|
||||
termin_range = termin.get_item(id)
|
||||
if not termin_range:
|
||||
@@ -353,22 +325,19 @@ def get_available(id):
|
||||
date_start = termin_range.get('date_start')
|
||||
date_end = termin_range.get('date_end')
|
||||
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:
|
||||
total_slots = int(termin_range.get('slots', 0) or 0)
|
||||
except Exception:
|
||||
total_slots = 0
|
||||
|
||||
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:
|
||||
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 []
|
||||
|
||||
# Normalize booked entries to dicts for easier consumption
|
||||
normalized = []
|
||||
for s in booked:
|
||||
if isinstance(s, (list, tuple)) and len(s) >= 2:
|
||||
@@ -388,7 +357,8 @@ def get_available(id):
|
||||
'date_start': date_start,
|
||||
'date_end': date_end,
|
||||
'time_span': time_span,
|
||||
'slot_lenght': slot_lenght,
|
||||
'slot_length': slot_length,
|
||||
'slot_lenght': slot_length,
|
||||
'slots_total': total_slots,
|
||||
'slots_booked': normalized,
|
||||
'slots_left': slots_left,
|
||||
@@ -398,22 +368,10 @@ def get_available(id):
|
||||
return {}
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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()
|
||||
if not user_name:
|
||||
return []
|
||||
@@ -459,6 +417,7 @@ def get_user_upcoming_events(user: str, limit: int = 25) -> list[dict]:
|
||||
'note': str(item.get('note') or ''),
|
||||
'link': link,
|
||||
'calendar_link': calendar_link if item.get('calendar_enabled') else None,
|
||||
'title': str(item.get('title') or ''),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -169,12 +169,14 @@ def configure():
|
||||
end = request.form.get('end_date')
|
||||
time = request.form.get('time_frame')
|
||||
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', '')
|
||||
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
|
||||
|
||||
if not start or not end or not time or not slots_amount or not slot_lenght:
|
||||
# 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(
|
||||
'termin_configure.html',
|
||||
@@ -183,7 +185,8 @@ def configure():
|
||||
email_service_enabled=cfg.EMAIL_ENABLED,
|
||||
)
|
||||
|
||||
result = appointment_service.new(start, end, time, slots_amount, slot_lenght, session["username"], mail, note, calendar_enabled=add_to_calendar)
|
||||
# Variablen im Funktionsaufruf aktualisiert
|
||||
result = appointment_service.new(start, end, time, slots_amount, slot_length, session["username"], mail, note, calendar_enabled=add_to_calendar, title=title)
|
||||
flash('Der Terminplan wurde angelegt.', 'success')
|
||||
return render_template(
|
||||
'termin_configure.html',
|
||||
@@ -192,6 +195,7 @@ def configure():
|
||||
calendar_link=result.get('calendar_link'),
|
||||
add_to_calendar=add_to_calendar,
|
||||
email_service_enabled=cfg.EMAIL_ENABLED,
|
||||
title=title,
|
||||
)
|
||||
elif request.method == "GET":
|
||||
return render_template(
|
||||
@@ -201,6 +205,7 @@ def configure():
|
||||
calendar_link=None,
|
||||
add_to_calendar=False,
|
||||
email_service_enabled=cfg.EMAIL_ENABLED,
|
||||
title=None,
|
||||
)
|
||||
|
||||
|
||||
@@ -214,8 +219,10 @@ def calendar_export(appointment_id):
|
||||
if not ics_content:
|
||||
return _appointment_not_found_response()
|
||||
|
||||
title = str(request.args.get('title', '') or '').strip() or appointment_id
|
||||
|
||||
response = Response(ics_content, mimetype='text/calendar; charset=utf-8')
|
||||
response.headers['Content-Disposition'] = f'attachment; filename=terminplan-{appointment_id}.ics'
|
||||
response.headers['Content-Disposition'] = f'attachment; filename=terminplan-{title}-{appointment_id}.ics'
|
||||
return response
|
||||
|
||||
|
||||
@@ -231,8 +238,10 @@ def client_slot_calendar_export(appointment_id):
|
||||
if not ics_content:
|
||||
return _appointment_not_found_response()
|
||||
|
||||
title = str(request.args.get('title', '') or '').strip() or appointment_id
|
||||
|
||||
response = Response(ics_content, mimetype='text/calendar; charset=utf-8')
|
||||
response.headers['Content-Disposition'] = f'attachment; filename=termin-{appointment_id}-{slot_start.replace(" ", "_").replace(":", "")}.ics'
|
||||
response.headers['Content-Disposition'] = f'attachment; filename=termin-{title}-{appointment_id}-{slot_start.replace(" ", "_").replace(":", "")}.ics'
|
||||
return response
|
||||
|
||||
@appoint_bp.route('/')
|
||||
|
||||
@@ -15,3 +15,4 @@ openpyxl
|
||||
cryptography>=42.0.0
|
||||
pywebpush
|
||||
py-vapid>=1.9.0
|
||||
beautifulsoup4
|
||||
@@ -1295,11 +1295,6 @@
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if 'username' in session %}
|
||||
{% if current_permissions.pages.get('my_borrowed_items', True) %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}" data-tutorial-tip="Hier sehen Sie Ihre bibliotheksbezogenen Ausleihen.">Meine Medien</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_permissions.pages.get('tutorial_page', True) %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}" data-tutorial-tip="Nutzen Sie das Tutorial, um die Bibliotheksfunktionen kennenzulernen.">Tutorial</a>
|
||||
@@ -1326,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>
|
||||
|
||||
+363
-329
@@ -431,7 +431,7 @@
|
||||
type="text"
|
||||
id="librarySearch"
|
||||
class="library-search-input"
|
||||
placeholder="Nach Titel, Autor, ISBN suchen..."
|
||||
placeholder="Nach Titel, ISBN suchen..."
|
||||
>
|
||||
<button
|
||||
id="filterToggleBtn"
|
||||
@@ -466,10 +466,6 @@
|
||||
</div>
|
||||
<div id="filterPanel" class="library-filter-panel">
|
||||
<div class="filter-row">
|
||||
<div class="filter-item">
|
||||
<label for="filterAuthor">Autor/Künstler:</label>
|
||||
<input type="text" id="filterAuthor" placeholder="z.B. Goethe">
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<label for="filterISBN">ISBN:</label>
|
||||
<input type="text" id="filterISBN" placeholder="z.B. 978-3...">
|
||||
@@ -516,7 +512,6 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 24%;">Titel</th>
|
||||
<th style="width: 14%;">Autor/Künstler</th>
|
||||
<th style="width: 12%;">ISBN/Code</th>
|
||||
<th style="width: 8%;">Typ</th>
|
||||
<th style="width: 8%;">Anzahl</th>
|
||||
@@ -549,15 +544,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
|
||||
<script>
|
||||
// State
|
||||
// =========================================================================
|
||||
// 1. GLOBAL STATE DEFINITIONS
|
||||
// =========================================================================
|
||||
let libraryItems = [];
|
||||
let filteredItems = [];
|
||||
let visibleItems = [];
|
||||
let currentSearchTerm = '';
|
||||
let activeFilters = {
|
||||
author: '',
|
||||
isbn: '',
|
||||
type: '',
|
||||
status: ''
|
||||
@@ -573,13 +570,20 @@
|
||||
const RENDER_BATCH_COUNT = 120;
|
||||
let renderedCount = INITIAL_RENDER_COUNT;
|
||||
let filterPanelOpen = false;
|
||||
|
||||
// Scanner Related State Variables
|
||||
let scannerInstance = null;
|
||||
let scannerRunning = false;
|
||||
let scannerRunning = false;
|
||||
let activeScannerCallback = null;
|
||||
let activeStudentCardId = '';
|
||||
let lastScanValue = '';
|
||||
let lastScanAt = 0;
|
||||
|
||||
const canEditLibraryItems = (document.getElementById('libraryTableContainer')?.dataset.canEdit === '1');
|
||||
|
||||
// =========================================================================
|
||||
// 2. DATA LOADING & FILTERING ENGINE
|
||||
// =========================================================================
|
||||
async function fetchLibraryPage(offset, limit) {
|
||||
const response = await fetch(`/api/library_items?offset=${offset}&limit=${limit}`);
|
||||
if (!response.ok) {
|
||||
@@ -588,7 +592,6 @@
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Load items progressively: first page immediately, rest in background
|
||||
async function loadLibraryItems() {
|
||||
if (pagingState.loading) return;
|
||||
pagingState.loading = true;
|
||||
@@ -627,7 +630,6 @@
|
||||
pagingState.total = page.total || pagingState.total;
|
||||
pagingState.hasMore = Boolean(page.has_more);
|
||||
|
||||
// Keep the current view reactive while avoiding a full rerender burst.
|
||||
applyFiltersAndSearch(false);
|
||||
|
||||
if ('requestIdleCallback' in window) {
|
||||
@@ -643,18 +645,16 @@
|
||||
}
|
||||
|
||||
function filterItems(items) {
|
||||
const author = activeFilters.author;
|
||||
const isbn = activeFilters.isbn;
|
||||
const type = activeFilters.type;
|
||||
const status = activeFilters.status;
|
||||
|
||||
return items.filter(item => {
|
||||
const authorMatch = !author || (item.Autor || item.Author || '').toLowerCase().includes(author);
|
||||
const isbnMatch = !isbn || (item.ISBN || item.Code_4 || item.Code4 || '').toLowerCase().includes(isbn);
|
||||
const typeMatch = !type || (item.ItemType || 'book') === type;
|
||||
const statusKey = item.LibraryDisplayStatus || (item.Verfuegbar === false ? 'borrowed' : 'available');
|
||||
const statusMatch = !status || statusKey === status;
|
||||
return authorMatch && isbnMatch && typeMatch && statusMatch;
|
||||
return isbnMatch && typeMatch && statusMatch;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -662,7 +662,6 @@
|
||||
if (!currentSearchTerm) return items;
|
||||
return items.filter(item =>
|
||||
(item.Name || '').toLowerCase().includes(currentSearchTerm) ||
|
||||
(item.Autor || item.Author || '').toLowerCase().includes(currentSearchTerm) ||
|
||||
(item.ISBN || item.Code_4 || item.Code4 || '').toLowerCase().includes(currentSearchTerm)
|
||||
);
|
||||
}
|
||||
@@ -679,7 +678,6 @@
|
||||
renderItems();
|
||||
}
|
||||
|
||||
// Display current view with incremental rendering
|
||||
function renderItems() {
|
||||
const tbody = document.getElementById('itemsTableBody');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
@@ -697,37 +695,33 @@
|
||||
emptyState.style.display = 'none';
|
||||
const rowsToRender = visibleItems.slice(0, renderedCount);
|
||||
|
||||
tbody.innerHTML = rowsToRender.map(item => `
|
||||
${(() => {
|
||||
const statusKey = item.LibraryDisplayStatus || (item.Verfuegbar === false ? 'borrowed' : 'available');
|
||||
const statusClass = statusKey === 'damaged' ? 'status-damaged' : (statusKey === 'borrowed' ? 'status-borrowed' : 'status-available');
|
||||
const statusText = statusKey === 'damaged' ? 'Defekt/Zerstört' : (statusKey === 'borrowed' ? 'Ausgeliehen' : 'Verfügbar');
|
||||
const actionLabel = statusKey === 'available' ? 'Ausleihen' : (statusKey === 'borrowed' ? 'Reservieren' : 'Nicht ausleihbar');
|
||||
const actionDisabled = statusKey === 'damaged' ? 'disabled' : '';
|
||||
return `
|
||||
<tr>
|
||||
<td class="table-title">${escapeHtml(item.Name || 'Untitled')}</td>
|
||||
<td>${escapeHtml(item.Autor || item.Author || '-')}</td>
|
||||
<td>${escapeHtml(item.ISBN || item.Code_4 || item.Code4 || '-')}</td>
|
||||
<td>${getItemTypeLabel(item.ItemType || 'book')}</td>
|
||||
<td style="font-weight:600; text-align:center;">${item.Quantity || item.GroupedDisplayCount || 1}</td>
|
||||
<td>
|
||||
<span class="table-status ${statusClass}">
|
||||
${statusText}
|
||||
</span>
|
||||
</td>
|
||||
<td class="table-actions">
|
||||
<button class="button" onclick="showItemDetail('${item._id}')">Details</button>
|
||||
<button class="button" style="background: #28a745; color: white;" onclick="borrowItem('${item._id}')" ${actionDisabled}>
|
||||
${actionLabel}
|
||||
</button>
|
||||
${canEditLibraryItems ? `<button class="button" style="background:#0ea5e9;color:#fff;" onclick="openEditLibraryItem('${item._id}')">Bearbeiten</button>` : ''}
|
||||
${canEditLibraryItems ? `<button class="button" style="background:#dc2626;color:#fff; margin-left:6px;" onclick="confirmDeleteLibraryItem('${item._id}')">Löschen</button>` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
})()}
|
||||
`).join('');
|
||||
tbody.innerHTML = rowsToRender.map(item => {
|
||||
const statusKey = item.LibraryDisplayStatus || (item.Verfuegbar === false ? 'borrowed' : 'available');
|
||||
const statusClass = statusKey === 'damaged' ? 'status-damaged' : (statusKey === 'borrowed' ? 'status-borrowed' : 'status-available');
|
||||
const statusText = statusKey === 'damaged' ? 'Defekt/Zerstört' : (statusKey === 'borrowed' ? 'Ausgeliehen' : 'Verfügbar');
|
||||
const actionLabel = statusKey === 'available' ? 'Ausleihen' : (statusKey === 'borrowed' ? 'Reservieren' : 'Nicht ausleihbar');
|
||||
const actionDisabled = statusKey === 'damaged' ? 'disabled' : '';
|
||||
return `
|
||||
<tr>
|
||||
<td class="table-title">${escapeHtml(item.Name || 'Untitled')}</td>
|
||||
<td>${escapeHtml(item.ISBN || item.Code_4 || item.Code4 || '-')}</td>
|
||||
<td>${getItemTypeLabel(item.ItemType || 'book')}</td>
|
||||
<td style="font-weight:600; text-align:center;">${item.Quantity || item.GroupedDisplayCount || 1}</td>
|
||||
<td>
|
||||
<span class="table-status ${statusClass}">
|
||||
${statusText}
|
||||
</span>
|
||||
</td>
|
||||
<td class="table-actions">
|
||||
<button class="button" onclick="showItemDetail('${item._id}')">Details</button>
|
||||
<button class="button" style="background: #28a745; color: white;" onclick="borrowItem('${item._id}')" ${actionDisabled}>
|
||||
${actionLabel}
|
||||
</button>
|
||||
${canEditLibraryItems ? `<button class="button" style="background:#0ea5e9;color:#fff;" onclick="openEditLibraryItem('${item._id}')">Bearbeiten</button>` : ''}
|
||||
${canEditLibraryItems ? `<button class="button" style="background:#dc2626;color:#fff; margin-left:6px;" onclick="confirmDeleteLibraryItem('${item._id}')">Löschen</button>` : ''}
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
const canLoadMoreRows = renderedCount < visibleItems.length;
|
||||
if (canLoadMoreRows) {
|
||||
@@ -742,13 +736,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm and call library item delete endpoint
|
||||
function confirmDeleteLibraryItem(itemId) {
|
||||
if (!itemId) return;
|
||||
if (!confirm('Dieses Bibliotheksmedium revisionssicher löschen? Diese Aktion deaktiviert alle zugehörigen Exemplare und archiviert Medien.')) return;
|
||||
deleteLibraryItem(itemId);
|
||||
}
|
||||
|
||||
async function deleteLibraryItem(itemId) {
|
||||
if (!itemId) return;
|
||||
document.body.style.cursor = 'wait';
|
||||
@@ -772,7 +759,6 @@
|
||||
}
|
||||
loadLibraryItems();
|
||||
} else {
|
||||
// Server redirected to HTML (flash messages). Reload to show state.
|
||||
location.reload();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -783,78 +769,183 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Filter toggle
|
||||
document.getElementById('filterToggleBtn').addEventListener('click', () => {
|
||||
filterPanelOpen = !filterPanelOpen;
|
||||
const panel = document.getElementById('filterPanel');
|
||||
const btn = document.getElementById('filterToggleBtn');
|
||||
panel.classList.toggle('open');
|
||||
btn.classList.toggle('active');
|
||||
});
|
||||
|
||||
// Apply filters
|
||||
document.getElementById('applyFilterBtn').addEventListener('click', () => {
|
||||
activeFilters.author = document.getElementById('filterAuthor').value.toLowerCase();
|
||||
activeFilters.isbn = document.getElementById('filterISBN').value.toLowerCase();
|
||||
activeFilters.type = document.getElementById('filterType').value;
|
||||
activeFilters.status = document.getElementById('filterStatus').value;
|
||||
applyFiltersAndSearch(true);
|
||||
});
|
||||
|
||||
// Clear filters
|
||||
document.getElementById('clearFilterBtn').addEventListener('click', () => {
|
||||
document.getElementById('filterAuthor').value = '';
|
||||
document.getElementById('filterISBN').value = '';
|
||||
document.getElementById('filterType').value = '';
|
||||
document.getElementById('filterStatus').value = '';
|
||||
activeFilters = { author: '', isbn: '', type: '', status: '' };
|
||||
applyFiltersAndSearch(true);
|
||||
});
|
||||
|
||||
// Search input
|
||||
document.getElementById('librarySearch').addEventListener('input', (e) => {
|
||||
currentSearchTerm = (e.target.value || '').toLowerCase();
|
||||
applyFiltersAndSearch(true);
|
||||
});
|
||||
|
||||
document.getElementById('loadMoreBtn').addEventListener('click', () => {
|
||||
renderedCount = Math.min(renderedCount + RENDER_BATCH_COUNT, visibleItems.length);
|
||||
renderItems();
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
function getItemTypeLabel(type) {
|
||||
const labels = { 'book': 'Buch', 'cd': 'CD', 'dvd': 'DVD', 'other': 'Sonstige' };
|
||||
return labels[type] || type;
|
||||
function confirmDeleteLibraryItem(itemId) {
|
||||
if (!itemId) return;
|
||||
if (!confirm('Dieses Bibliotheksmedium revisionssicher löschen? Diese Aktion deaktiviert alle zugehörigen Exemplare und archiviert Medien.')) return;
|
||||
deleteLibraryItem(itemId);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
// =========================================================================
|
||||
// 3. CORE SCANNER ROUTING ENGINE (QUAGGA2)
|
||||
// =========================================================================
|
||||
function startScanner(targetCallback) {
|
||||
const readerWrap = document.getElementById('scanReaderWrap');
|
||||
const toggleBtn = document.getElementById('toggleScannerBtn');
|
||||
|
||||
activeScannerCallback = targetCallback;
|
||||
|
||||
function showItemDetail(itemId) {
|
||||
// Load and show detail modal
|
||||
fetch(`/api/item_detail/${itemId}`)
|
||||
.then(r => r.text())
|
||||
.then(html => {
|
||||
document.getElementById('detailContent').innerHTML = html;
|
||||
document.getElementById('detailModal').style.display = 'flex';
|
||||
})
|
||||
.catch(err => console.error('Error loading detail:', err));
|
||||
}
|
||||
|
||||
function closeDetailModal() {
|
||||
document.getElementById('detailModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// Keep track of the scanner state globally/outer scope
|
||||
let scannerRunning = false;
|
||||
let lastScanValue = null;
|
||||
let lastScanAt = 0;
|
||||
let activeStudentCardId = ''; // Managed globally by your system
|
||||
if (readerWrap) readerWrap.style.display = 'block';
|
||||
setScanStatus('Initializing camera...', 'warn');
|
||||
|
||||
Quagga.init({
|
||||
inputStream: {
|
||||
name: "Live",
|
||||
type: "LiveStream",
|
||||
target: document.querySelector('#library-scanner-container'),
|
||||
constraints: {
|
||||
width: 640,
|
||||
height: 480,
|
||||
facingMode: "environment"
|
||||
},
|
||||
},
|
||||
decoder: {
|
||||
readers: [
|
||||
"code_128_reader",
|
||||
"ean_reader",
|
||||
"code_39_reader",
|
||||
"upc_reader",
|
||||
"codabar_reader",
|
||||
"i2of5_reader"
|
||||
]
|
||||
}
|
||||
}, function(err) {
|
||||
if (err) {
|
||||
console.error('Scanner start failed:', err);
|
||||
if (readerWrap) readerWrap.style.display = 'none';
|
||||
const detail = (err && (err.message || err.name)) ? ` (${err.message || err.name})` : '';
|
||||
setScanStatus(`Scanner konnte nicht gestartet werden${detail}`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
Quagga.start();
|
||||
scannerRunning = true;
|
||||
|
||||
if (!targetCallback && toggleBtn) {
|
||||
toggleBtn.textContent = 'Scanner stoppen';
|
||||
}
|
||||
setScanStatus('Scanner aktiv. Jetzt Code scannen.', 'warn');
|
||||
});
|
||||
}
|
||||
|
||||
function stopScanner() {
|
||||
if (!scannerRunning) return;
|
||||
|
||||
const readerWrap = document.getElementById('scanReaderWrap');
|
||||
const toggleBtn = document.getElementById('toggleScannerBtn');
|
||||
|
||||
Quagga.stop();
|
||||
scannerRunning = false;
|
||||
activeScannerCallback = null;
|
||||
|
||||
if (readerWrap) readerWrap.style.display = 'none';
|
||||
if (toggleBtn) toggleBtn.textContent = 'Scanner starten';
|
||||
setScanStatus('Scanner gestoppt.', 'warn');
|
||||
}
|
||||
|
||||
Quagga.onDetected(function(data) {
|
||||
if (!data || !data.codeResult || !data.codeResult.code) return;
|
||||
|
||||
const barcode = String(data.codeResult.code || '').trim();
|
||||
console.log("Barcode detected:", barcode);
|
||||
|
||||
const currentCallback = activeScannerCallback;
|
||||
stopScanner();
|
||||
|
||||
if (typeof currentCallback === "function") {
|
||||
currentCallback(barcode);
|
||||
} else {
|
||||
handleScanSuccess(barcode);
|
||||
}
|
||||
});
|
||||
|
||||
function handleScanSuccess(decodedText) {
|
||||
const scannedCode = normalizeScannedCode(decodedText);
|
||||
if (!scannedCode) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (scannedCode === lastScanValue && (now - lastScanAt) < 1500) {
|
||||
return;
|
||||
}
|
||||
lastScanValue = scannedCode;
|
||||
lastScanAt = now;
|
||||
|
||||
const mode = (document.getElementById('scanModeSelect') || {}).value || 'card_only';
|
||||
if (mode === 'card_only') {
|
||||
setActiveStudentCard(scannedCode);
|
||||
setScanStatus(`Ausweis gesetzt: ${activeStudentCardId}`, 'ok');
|
||||
return;
|
||||
}
|
||||
|
||||
processQuickToggleScan(scannedCode);
|
||||
}
|
||||
|
||||
async function processQuickToggleScan(scannedCode) {
|
||||
if (!activeStudentCardId) {
|
||||
setActiveStudentCard(scannedCode);
|
||||
setScanStatus(`Ausweis gesetzt: ${activeStudentCardId}`, 'ok');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setScanStatus('Verarbeite Mediencode...', 'warn');
|
||||
const response = await fetch('/api/library_scan_action', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
student_card_id: activeStudentCardId,
|
||||
item_code: scannedCode
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.ok) {
|
||||
setScanStatus(result.message || 'Scan-Aktion fehlgeschlagen.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.action === 'borrowed') {
|
||||
setScanStatus(`Ausgeliehen: ${result.item_name}`, 'ok');
|
||||
} else if (result.action === 'returned') {
|
||||
setScanStatus(`Zurückgegeben: ${result.item_name}`, 'ok');
|
||||
} else {
|
||||
setScanStatus(result.message || 'Aktion durchgeführt.', 'ok');
|
||||
}
|
||||
|
||||
await loadLibraryItems();
|
||||
} catch (err) {
|
||||
console.error('Quick scan action failed:', err);
|
||||
setScanStatus('Fehler beim Verarbeiten des Scans.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function scanIntoEditCode() {
|
||||
const scanReaderWrap = document.getElementById('scanReaderWrap');
|
||||
const editCodeInput = document.getElementById('edit-code4');
|
||||
const scanEditBtn = document.getElementById('scan-edit-code-btn');
|
||||
if (!scanReaderWrap || !editCodeInput || !scanEditBtn) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (scannerRunning && scanReaderWrap.style.display !== 'none') {
|
||||
stopScanner();
|
||||
scanEditBtn.textContent = 'Barcode scannen';
|
||||
return;
|
||||
}
|
||||
|
||||
scanEditBtn.textContent = 'Scanner schließen';
|
||||
|
||||
startScanner(function(decodedText) {
|
||||
editCodeInput.value = decodedText;
|
||||
if(typeof validateCodeField === "function") {
|
||||
validateCodeField(editCodeInput, document.getElementById('edit-item-id')?.value || null);
|
||||
}
|
||||
scanEditBtn.textContent = 'Barcode scannen';
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 4. UI INTERACTIONS & UTILITIES
|
||||
// =========================================================================
|
||||
function borrowItem(itemId) {
|
||||
const selectedItem = (libraryItems || []).find(item => item._id === itemId);
|
||||
if (selectedItem && selectedItem.LibraryDisplayStatus === 'damaged') {
|
||||
@@ -922,157 +1013,53 @@
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
|
||||
function setScanStatus(message, kind = '') {
|
||||
const el = document.getElementById('scanStatus');
|
||||
if (!el) return;
|
||||
el.textContent = message;
|
||||
el.classList.remove('ok', 'warn', 'error');
|
||||
if (kind) {
|
||||
el.classList.add(kind);
|
||||
}
|
||||
if (kind) el.classList.add(kind);
|
||||
}
|
||||
|
||||
function setActiveStudentCard(cardId) {
|
||||
activeStudentCardId = (cardId || '').trim().toUpperCase();
|
||||
const input = document.getElementById('activeStudentCard');
|
||||
if (input) {
|
||||
input.value = activeStudentCardId;
|
||||
}
|
||||
if (input) input.value = activeStudentCardId;
|
||||
}
|
||||
|
||||
function normalizeScannedCode(code) {
|
||||
return (code || '').trim();
|
||||
}
|
||||
|
||||
async function processQuickToggleScan(scannedCode) {
|
||||
if (!activeStudentCardId) {
|
||||
setActiveStudentCard(scannedCode);
|
||||
setScanStatus(`Ausweis gesetzt: ${activeStudentCardId}`, 'ok');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setScanStatus('Verarbeite Mediencode...', 'warn');
|
||||
const response = await fetch('/api/library_scan_action', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
student_card_id: activeStudentCardId,
|
||||
item_code: scannedCode
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.ok) {
|
||||
setScanStatus(result.message || 'Scan-Aktion fehlgeschlagen.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.action === 'borrowed') {
|
||||
setScanStatus(`Ausgeliehen: ${result.item_name}`, 'ok');
|
||||
} else if (result.action === 'returned') {
|
||||
setScanStatus(`Zurückgegeben: ${result.item_name}`, 'ok');
|
||||
} else {
|
||||
setScanStatus(result.message || 'Aktion durchgeführt.', 'ok');
|
||||
}
|
||||
|
||||
await loadLibraryItems();
|
||||
} catch (err) {
|
||||
console.error('Quick scan action failed:', err);
|
||||
setScanStatus('Fehler beim Verarbeiten des Scans.', 'error');
|
||||
}
|
||||
|
||||
function getItemTypeLabel(type) {
|
||||
const labels = { 'book': 'Buch', 'cd': 'CD', 'dvd': 'DVD', 'other': 'Sonstige' };
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
function handleScanSuccess(decodedText) {
|
||||
const scannedCode = normalizeScannedCode(decodedText);
|
||||
if (!scannedCode) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (scannedCode === lastScanValue && (now - lastScanAt) < 1500) {
|
||||
return;
|
||||
}
|
||||
lastScanValue = scannedCode;
|
||||
lastScanAt = now;
|
||||
|
||||
const mode = (document.getElementById('scanModeSelect') || {}).value || 'card_only';
|
||||
if (mode === 'card_only') {
|
||||
setActiveStudentCard(scannedCode);
|
||||
setScanStatus(`Ausweis gesetzt: ${activeStudentCardId}`, 'ok');
|
||||
return;
|
||||
}
|
||||
|
||||
processQuickToggleScan(scannedCode);
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Global Quagga reader hook
|
||||
Quagga.onDetected(function(data) {
|
||||
if (data && data.codeResult && data.codeResult.code) {
|
||||
handleScanSuccess(data.codeResult.code);
|
||||
}
|
||||
});
|
||||
|
||||
async function startScanner() {
|
||||
if (scannerRunning) return;
|
||||
|
||||
const readerWrap = document.getElementById('scanReaderWrap');
|
||||
const toggleBtn = document.getElementById('toggleScannerBtn');
|
||||
if (readerWrap) readerWrap.style.display = 'block';
|
||||
|
||||
setScanStatus('Initializing camera...', 'warn');
|
||||
|
||||
Quagga.init({
|
||||
inputStream: {
|
||||
name: "Live",
|
||||
type: "LiveStream",
|
||||
// Corrected to target your relative viewport wrapper child element
|
||||
target: document.querySelector('#library-scanner-container'),
|
||||
constraints: {
|
||||
width: 640,
|
||||
height: 480,
|
||||
facingMode: "environment" // Force rear camera lenses
|
||||
},
|
||||
},
|
||||
decoder: {
|
||||
// Limited precisely down to your standard barcode layouts
|
||||
readers: [
|
||||
"code_128_reader",
|
||||
"ean_reader",
|
||||
"code_39_reader",
|
||||
"upc_reader",
|
||||
"codabar_reader",
|
||||
"i2of5_reader" // Replaces old ITF layout configuration mapping
|
||||
]
|
||||
}
|
||||
}, function(err) {
|
||||
if (err) {
|
||||
console.error('Scanner start failed:', err);
|
||||
if (readerWrap) readerWrap.style.display = 'none';
|
||||
const detail = (err && (err.message || err.name)) ? ` (${err.message || err.name})` : '';
|
||||
setScanStatus(`Scanner konnte nicht gestartet werden${detail}`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
Quagga.start();
|
||||
scannerRunning = true;
|
||||
if (toggleBtn) toggleBtn.textContent = 'Scanner stoppen';
|
||||
setScanStatus('Scanner aktiv. Jetzt Code scannen.', 'warn');
|
||||
});
|
||||
|
||||
function showItemDetail(itemId) {
|
||||
fetch(`/api/item_detail/${itemId}`)
|
||||
.then(r => r.text())
|
||||
.then(html => {
|
||||
document.getElementById('detailContent').innerHTML = html;
|
||||
document.getElementById('detailModal').style.display = 'flex';
|
||||
})
|
||||
.catch(err => console.error('Error loading detail:', err));
|
||||
}
|
||||
|
||||
async function stopScanner() {
|
||||
if (!scannerRunning) return;
|
||||
const readerWrap = document.getElementById('scanReaderWrap');
|
||||
const toggleBtn = document.getElementById('toggleScannerBtn');
|
||||
|
||||
Quagga.stop();
|
||||
|
||||
scannerRunning = false;
|
||||
if (readerWrap) readerWrap.style.display = 'none';
|
||||
if (toggleBtn) toggleBtn.textContent = 'Scanner starten';
|
||||
setScanStatus('Scanner gestoppt.', 'warn');
|
||||
|
||||
function closeDetailModal() {
|
||||
document.getElementById('detailModal').style.display = 'none';
|
||||
}
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// 5. EVENT LISTENERS INITIALIZATION & ON-LOAD INITIALIZER
|
||||
// =========================================================================
|
||||
function wireScannerUi() {
|
||||
const toggleBtn = document.getElementById('toggleScannerBtn');
|
||||
const resetBtn = document.getElementById('resetCardBtn');
|
||||
@@ -1083,7 +1070,7 @@
|
||||
if (scannerRunning) {
|
||||
await stopScanner();
|
||||
} else {
|
||||
await startScanner();
|
||||
await startScanner(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1100,84 +1087,135 @@
|
||||
if (modeSelect.value === 'quick_toggle' && !activeStudentCardId) {
|
||||
setScanStatus('Schnellmodus: zuerst Schülerausweis scannen.', 'warn');
|
||||
} else if (modeSelect.value === 'card_only') {
|
||||
setScanStatus('Modus aktiv: Nur Ausweis erfassen.', 'warn');
|
||||
} else {
|
||||
setScanStatus('Schnellmodus aktiv: jetzt Mediencode scannen.', 'warn');
|
||||
setScanStatus('Nur Ausweis-Modus aktiv.', 'warn');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getLibraryItemById(itemId) {
|
||||
return libraryItems.find(i => String(i._id) === String(itemId));
|
||||
}
|
||||
|
||||
function openEditLibraryItem(itemId) {
|
||||
const item = getLibraryItemById(itemId);
|
||||
if (!item) {
|
||||
alert('Element nicht gefunden.');
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = document.getElementById('editLibraryModal');
|
||||
document.getElementById('editLibraryItemId').value = item._id || '';
|
||||
document.getElementById('editLibraryName').value = item.Name || '';
|
||||
document.getElementById('editLibraryAuthor').value = item.Autor || item.Author || '';
|
||||
document.getElementById('editLibraryDescription').value = item.Beschreibung || '';
|
||||
document.getElementById('editLibraryLocation').value = item.Ort || '';
|
||||
document.getElementById('editLibraryIsbn').value = item.ISBN || '';
|
||||
document.getElementById('editLibraryCode4').value = item.Code_4 || '';
|
||||
document.getElementById('editLibraryType').value = (item.ItemType || 'book');
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeEditLibraryModal() {
|
||||
const modal = document.getElementById('editLibraryModal');
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
|
||||
async function submitEditLibraryItem(event) {
|
||||
event.preventDefault();
|
||||
const itemId = document.getElementById('editLibraryItemId').value;
|
||||
const payload = {
|
||||
name: document.getElementById('editLibraryName').value,
|
||||
autor: document.getElementById('editLibraryAuthor').value,
|
||||
beschreibung: document.getElementById('editLibraryDescription').value,
|
||||
ort: document.getElementById('editLibraryLocation').value,
|
||||
isbn: document.getElementById('editLibraryIsbn').value,
|
||||
code_4: document.getElementById('editLibraryCode4').value,
|
||||
item_type: document.getElementById('editLibraryType').value
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/library_item/${itemId}/update`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.ok) {
|
||||
alert(result.message || 'Bearbeiten fehlgeschlagen.');
|
||||
return;
|
||||
}
|
||||
closeEditLibraryModal();
|
||||
await loadLibraryItems();
|
||||
setScanStatus('Bibliotheksmedium aktualisiert.', 'ok');
|
||||
} catch (err) {
|
||||
console.error('Library edit failed:', err);
|
||||
alert('Fehler beim Bearbeiten des Bibliotheksmediums.');
|
||||
}
|
||||
}
|
||||
|
||||
// Load on page init
|
||||
// Run when DOM structure is entirely ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadLibraryItems();
|
||||
wireScannerUi();
|
||||
wireScannerUi(); // Setup scanner control buttons
|
||||
loadLibraryItems(); // Fetch your database items right away!
|
||||
|
||||
// Safely connect standard Filters and Search inputs inside DOMContentLoaded
|
||||
const filterToggleBtn = document.getElementById('filterToggleBtn');
|
||||
if (filterToggleBtn) {
|
||||
filterToggleBtn.addEventListener('click', () => {
|
||||
filterPanelOpen = !filterPanelOpen;
|
||||
document.getElementById('filterPanel').classList.toggle('open');
|
||||
filterToggleBtn.classList.toggle('active');
|
||||
});
|
||||
}
|
||||
|
||||
const applyFilterBtn = document.getElementById('applyFilterBtn');
|
||||
if (applyFilterBtn) {
|
||||
applyFilterBtn.addEventListener('click', () => {
|
||||
activeFilters.isbn = document.getElementById('filterISBN').value.toLowerCase();
|
||||
activeFilters.type = document.getElementById('filterType').value;
|
||||
activeFilters.status = document.getElementById('filterStatus').value;
|
||||
applyFiltersAndSearch(true);
|
||||
});
|
||||
}
|
||||
|
||||
const clearFilterBtn = document.getElementById('clearFilterBtn');
|
||||
if (clearFilterBtn) {
|
||||
clearFilterBtn.addEventListener('click', () => {
|
||||
document.getElementById('filterISBN').value = '';
|
||||
document.getElementById('filterType').value = '';
|
||||
document.getElementById('filterStatus').value = '';
|
||||
activeFilters = { isbn: '', type: '', status: '' };
|
||||
applyFiltersAndSearch(true);
|
||||
});
|
||||
}
|
||||
|
||||
const librarySearch = document.getElementById('librarySearch');
|
||||
if (librarySearch) {
|
||||
librarySearch.addEventListener('input', (e) => {
|
||||
currentSearchTerm = (e.target.value || '').toLowerCase();
|
||||
applyFiltersAndSearch(true);
|
||||
});
|
||||
}
|
||||
|
||||
const loadMoreBtn = document.getElementById('loadMoreBtn');
|
||||
if (loadMoreBtn) {
|
||||
loadMoreBtn.addEventListener('click', () => {
|
||||
renderedCount = Math.min(renderedCount + RENDER_BATCH_COUNT, visibleItems.length);
|
||||
renderItems();
|
||||
});
|
||||
}
|
||||
|
||||
// Edit Modal Form processing
|
||||
const editForm = document.getElementById('editLibraryForm');
|
||||
if (editForm) {
|
||||
editForm.addEventListener('submit', submitEditLibraryItem);
|
||||
editForm.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const itemId = document.getElementById('editLibraryItemId').value;
|
||||
|
||||
const updatedData = {
|
||||
name: document.getElementById('editLibraryName').value,
|
||||
item_type: document.getElementById('editLibraryType').value,
|
||||
isbn: document.getElementById('editLibraryIsbn').value,
|
||||
code_4: document.getElementById('editLibraryCode4').value,
|
||||
ort: document.getElementById('editLibraryLocation').value,
|
||||
beschreibung: document.getElementById('editLibraryDescription').value
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/library_item/${itemId}/update`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token }}',
|
||||
'X-CSRF-Token': '{{ csrf_token }}'
|
||||
},
|
||||
body: JSON.stringify(updatedData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.ok) {
|
||||
alert(result.message || 'Medium erfolgreich aktualisiert!');
|
||||
closeEditLibraryModal();
|
||||
|
||||
pagingState.loading = false;
|
||||
loadLibraryItems();
|
||||
} else {
|
||||
alert(result.message || 'Fehler beim Speichern der Änderungen.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Update failed:', error);
|
||||
alert('Netzwerkfehler beim Aktualisieren des Mediums.');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Modal Control Functions (accessible globally from table buttons)
|
||||
function openEditLibraryItem(itemId) {
|
||||
const item = libraryItems.find(i => i._id === itemId);
|
||||
|
||||
if (!item) {
|
||||
alert('Fehler: Das Medium konnte nicht gefunden werden.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Populate the form fields - Using correct German properties from MongoDB fields
|
||||
document.getElementById('editLibraryItemId').value = item._id || '';
|
||||
document.getElementById('editLibraryName').value = item.Name || '';
|
||||
document.getElementById('editLibraryType').value = item.ItemType || 'book';
|
||||
document.getElementById('editLibraryIsbn').value = item.ISBN || '';
|
||||
document.getElementById('editLibraryCode4').value = item.Code_4 || item.Code4 || '';
|
||||
document.getElementById('editLibraryLocation').value = item.Ort || '';
|
||||
document.getElementById('editLibraryDescription').value = item.Beschreibung || '';
|
||||
|
||||
document.getElementById('editLibraryModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeEditLibraryModal() {
|
||||
document.getElementById('editLibraryModal').style.display = 'none';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id="editLibraryModal" class="modal" style="display:none;">
|
||||
@@ -1191,10 +1229,6 @@
|
||||
<label for="editLibraryName">Titel</label>
|
||||
<input id="editLibraryName" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="editLibraryAuthor">Autor/Künstler</label>
|
||||
<input id="editLibraryAuthor">
|
||||
</div>
|
||||
<div>
|
||||
<label for="editLibraryType">Medientyp</label>
|
||||
<select id="editLibraryType">
|
||||
@@ -1229,4 +1263,4 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@@ -28,11 +28,10 @@
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab-legal" data-bs-toggle="tab" data-bs-target="#pane-legal"
|
||||
type="button" role="tab">Rechtsgrundlage</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- ── NOTICE ────────────────────────────────────────────────────── -->
|
||||
<div class="tab-pane fade" id="pane-notice" role="tabpanel">
|
||||
<div class="tab-pane" id="pane-notice" role="tabpanel">
|
||||
<div class="license-content">
|
||||
<h2>NOTICE – Urheberrechtliche Hinweise</h2>
|
||||
<p><strong>Invario Modul Suite</strong><br>Copyright © 2026 Invario UG</p>
|
||||
@@ -59,14 +58,14 @@
|
||||
|
||||
<h4>Frontend / Laufzeit-Assets</h4>
|
||||
<ul>
|
||||
<li>html5-qrcode (MIT) © 2020-2025 Manoj Brahmbhatt (mebjas)</li>
|
||||
<li>quagga2 (MIT) © 2014 Christoph Oberhofer, © 2019 Eric Blade and contributors</li>
|
||||
</ul>
|
||||
<hr>
|
||||
</div>
|
||||
</div><!-- /#pane-notice -->
|
||||
|
||||
<!-- ── DATENSCHUTZ ───────────────────────────────────────────────── -->
|
||||
<div class="tab-pane fade" id="pane-privacy" role="tabpanel">
|
||||
<div class="tab-pane" id="pane-privacy" role="tabpanel">
|
||||
<div class="license-content">
|
||||
<h2>Datenschutzerklärung (Privacy Policy)</h2>
|
||||
|
||||
@@ -108,7 +107,7 @@
|
||||
</div><!-- /#pane-privacy -->
|
||||
|
||||
<!-- ── SICHERHEIT ────────────────────────────────────────────────── -->
|
||||
<div class="tab-pane fade" id="pane-security" role="tabpanel">
|
||||
<div class="tab-pane" id="pane-security" role="tabpanel">
|
||||
<div class="license-content">
|
||||
<h2>Sicherheitsrichtlinie (Security Policy)</h2>
|
||||
|
||||
@@ -129,7 +128,7 @@
|
||||
</div><!-- /#pane-security -->
|
||||
|
||||
<!-- ── DATENVERARBEITUNG ─────────────────────────────────────────── -->
|
||||
<div class="tab-pane fade" id="pane-data" role="tabpanel">
|
||||
<div class="tab-pane" id="pane-data" role="tabpanel">
|
||||
<div class="license-content">
|
||||
<h2>Dokumentation der Datenverarbeitung (VVT)</h2>
|
||||
|
||||
@@ -157,7 +156,7 @@
|
||||
</div><!-- /#pane-data -->
|
||||
|
||||
<!-- ── RECHTSGRUNDLAGE ───────────────────────────────────────────── -->
|
||||
<div class="tab-pane fade" id="pane-legal" role="tabpanel">
|
||||
<div class="tab-pane" id="pane-legal" role="tabpanel">
|
||||
<div class="license-content">
|
||||
<h2>Rechtsgrundlage der Nutzung</h2>
|
||||
<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>
|
||||
</ol>
|
||||
</div>
|
||||
</div><!-- /#pane-legal -->
|
||||
|
||||
</div><!-- /.tab-content -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -373,7 +373,7 @@
|
||||
|
||||
<div class="filter-group">
|
||||
<div class="filter-header">
|
||||
<label>Thema:</label>
|
||||
<label>Schlagwort:</label>
|
||||
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter3-dropdown')">▼</button>
|
||||
<button type="button" class="clear-filter" onclick="clearFilter(3)">Clear</button>
|
||||
</div>
|
||||
@@ -420,7 +420,7 @@
|
||||
<span>Ort</span>
|
||||
<span>Unterrichtsfach</span>
|
||||
<span>Jahrgangsstufe</span>
|
||||
<span>Thema</span>
|
||||
<span>Schlagwort</span>
|
||||
<span>Barcode</span>
|
||||
</div>
|
||||
<div id="items-container" class="items-container">
|
||||
@@ -1057,7 +1057,7 @@
|
||||
<p class="item-col-location"><strong>Ort:</strong> ${item.Ort || '-'}</p>
|
||||
<p class="item-col-filter1"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p>
|
||||
<p class="item-col-filter2"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p>
|
||||
<p class="item-col-filter3"><strong>Thema:</strong> ${filter3Display}${filter3More}</p>
|
||||
<p class="item-col-filter3"><strong>Schlagwort:</strong> ${filter3Display}${filter3More}</p>
|
||||
<p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
|
||||
<p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p>
|
||||
${isGroupedItem ? `<p class="item-col-count"><strong>Verfügbar:</strong> ${availableGroupedCount}</p>` : ''}
|
||||
@@ -1682,7 +1682,7 @@
|
||||
</div>
|
||||
|
||||
<div class="detail-group">
|
||||
<div class="detail-label">Thema:</div>
|
||||
<div class="detail-label">Schlagwort:</div>
|
||||
<div class="detail-value">${filter3Array.join(', ') || '-'}</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2395,7 +2395,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
|
||||
<div class="filter-group">
|
||||
<div class="filter-header">
|
||||
<label>Thema:</label>
|
||||
<label>Schlagwort:</label>
|
||||
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter3-dropdown')">▼</button>
|
||||
<button type="button" class="clear-filter" onclick="clearFilter(3)">Clear</button>
|
||||
</div>
|
||||
@@ -2446,7 +2446,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
<span>Ort</span>
|
||||
<span>Unterrichtsfach</span>
|
||||
<span>Jahrgangsstufe</span>
|
||||
<span>Thema</span>
|
||||
<span>Schlagwort</span>
|
||||
<span>Barcode</span>
|
||||
<span>Anzahl</span>
|
||||
</div>
|
||||
@@ -2567,7 +2567,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Thema:</h3>
|
||||
<h3>Schlagwort:</h3>
|
||||
<div class="multi-filter">
|
||||
<div class="form-group">
|
||||
<label for="edit-filter3-1">Wert 1:</label>
|
||||
@@ -3686,7 +3686,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
<p class="item-col-location"><strong>Ort:</strong> ${item.Ort || '-'}</p>
|
||||
<p class="item-col-filter1"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p>
|
||||
<p class="item-col-filter2"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p>
|
||||
<p class="item-col-filter3"><strong>Thema:</strong> ${filter3Display}${filter3More}</p>
|
||||
<p class="item-col-filter3"><strong>Schlagwort:</strong> ${filter3Display}${filter3More}</p>
|
||||
<p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
|
||||
<p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p>
|
||||
${hasDamage ? `<div class="damage-badge">${damageCount > 0 ? `Schäden gemeldet: ${damageCount}` : 'Schäden gemeldet'}</div>` : ''}
|
||||
@@ -4229,7 +4229,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
document.getElementById('edit-filter2-3').value = filter2Array[2] || '';
|
||||
document.getElementById('edit-filter2-4').value = filter2Array[3] || '';
|
||||
|
||||
// Fill in filter 3 (Thema)
|
||||
// Fill in filter 3 (Schlagwort)
|
||||
const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 : (item.Filter3 ? [item.Filter3] : []);
|
||||
document.getElementById('edit-filter3-1').value = filter3Array[0] || '';
|
||||
document.getElementById('edit-filter3-2').value = filter3Array[1] || '';
|
||||
@@ -4543,7 +4543,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
</div>
|
||||
|
||||
<div class="detail-group">
|
||||
<div class="detail-label">Thema:</div>
|
||||
<div class="detail-label">Schlagwort:</div>
|
||||
<div class="detail-value">${filter3Array.join(', ') || '-'}</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<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</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>
|
||||
|
||||
<div class="p-3 rounded-3 bg-light mb-3">
|
||||
|
||||
@@ -14,13 +14,17 @@
|
||||
<div class="card-body p-4 p-md-5 bg-white">
|
||||
<form method="post" action="{{ url_for('terminplaner.configure') }}" class="vstack gap-3">
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label for="title" class="form-label fw-semibold">Titel des Terminplans</label>
|
||||
<input type="text" id="title" name="title" class="form-control form-control-lg" placeholder="z.B. Elternsprechtag Klasse 10a" value="{{ title or '' }}">
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<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 class="col-12 col-md-6">
|
||||
<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>
|
||||
|
||||
@@ -28,52 +32,63 @@
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-2">
|
||||
<div>
|
||||
<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>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" id="build_time_frame">Tage aus Zeitraum erzeugen</button>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<label for="default_day_start" class="form-label fw-semibold">Standard-Startzeit</label>
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<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">
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<label for="default_day_end" class="form-label fw-semibold">Standard-Endzeit</label>
|
||||
<input type="time" id="default_day_end" class="form-control" value="12:00">
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<label for="default_day_end" class="form-label fw-semibold">Standard-Ende</label>
|
||||
<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 id="time_frame_days" class="vstack gap-2"></div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label for="time_frame" class="form-label fw-semibold">Gespeichertes Zeitfenster</label>
|
||||
<textarea id="time_frame" name="time_frame" class="form-control font-monospace" rows="5" placeholder="Wird automatisch aus den Tagen erzeugt" required></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>
|
||||
<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" readonly></textarea>
|
||||
<div class="form-text">Pausen teilen den Tag automatisch in zwei buchbare Blöcke, sodass die Pause sicher blockiert ist.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="slots_amounts" class="form-label fw-semibold">Anzahl Slots</label>
|
||||
<input type="number" id="slots_amounts" name="slots_amounts" class="form-control" min="1" value="1" required>
|
||||
<label for="slot_length" class="form-label fw-semibold">Slot-Länge in Minuten</label>
|
||||
<input type="number" id="slot_length" name="slot_length" class="form-control form-control-lg" min="1" value="45">
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="slot_lenght" class="form-label fw-semibold">Slot-Länge in Minuten</label>
|
||||
<input type="number" id="slot_lenght" name="slot_lenght" class="form-control" min="1" value="45" required>
|
||||
<label class="form-label fw-semibold text-primary">Automatisch berechnete Slots</label>
|
||||
<div id="slots_amounts_display" class="form-control form-control-lg bg-primary-subtle text-primary fw-bold d-flex align-items-center">0</div>
|
||||
<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>
|
||||
<label for="mail" class="form-label fw-semibold">Empfänger-Mails</label>
|
||||
<input type="text" id="mail" name="mail" class="form-control" placeholder="adresse1@schule.de, adresse2@schule.de">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="note" class="form-label fw-semibold">Notiz</label>
|
||||
<textarea id="note" name="note" class="form-control" rows="4" placeholder="Optionaler Einführungstext für die Mail"></textarea>
|
||||
</div>
|
||||
{% if mail_service_enabled %}
|
||||
<div>
|
||||
<label for="mail" class="form-label fw-semibold">Empfänger-Mails</label>
|
||||
<input type="text" id="mail" name="mail" class="form-control" placeholder="adresse1@schule.de, adresse2@schule.de">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="note" class="form-label fw-semibold">Notiz</label>
|
||||
<textarea id="note" name="note" class="form-control" rows="4" placeholder="Optionaler Einführungstext für die Mail"></textarea>
|
||||
</div>
|
||||
{% endif %}
|
||||
<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 %}>
|
||||
<label class="form-check-label fw-semibold" for="add_to_calendar">Kalendereintrag für den Nutzer erzeugen</label>
|
||||
@@ -92,7 +107,7 @@
|
||||
<div class="alert alert-success mt-4 shadow-sm rounded-4">
|
||||
<div class="fw-bold mb-1">Buchungslink erstellt</div>
|
||||
<div class="mb-2">
|
||||
{% if email_service_enabled %}
|
||||
{% if mail_service_enabled %}
|
||||
Teilen Sie diesen Link mit den Teilnehmenden oder versenden Sie ihn direkt per E-Mail.
|
||||
{% else %}
|
||||
Der E-Mail-Service ist deaktiviert. Teilen Sie diesen Link manuell mit den Teilnehmenden.
|
||||
@@ -117,30 +132,31 @@
|
||||
const buildButton = document.getElementById('build_time_frame');
|
||||
const daysContainer = document.getElementById('time_frame_days');
|
||||
const timeFrameTextarea = document.getElementById('time_frame');
|
||||
|
||||
const defaultStartInput = document.getElementById('default_day_start');
|
||||
const defaultEndInput = document.getElementById('default_day_end');
|
||||
const defaultPauseStartInput = document.getElementById('default_pause_start');
|
||||
const defaultPauseEndInput = document.getElementById('default_pause_end');
|
||||
|
||||
const slotLengthInput = document.getElementById('slot_length');
|
||||
const slotsAmountsInput = document.getElementById('slots_amounts');
|
||||
const slotsAmountsDisplay = document.getElementById('slots_amounts_display'); // Neu: Anzeige-Element
|
||||
|
||||
if (!startDateInput || !endDateInput || !buildButton || !daysContainer || !timeFrameTextarea || !defaultStartInput || !defaultEndInput) {
|
||||
if (!startDateInput || !endDateInput || !buildButton || !daysContainer || !timeFrameTextarea) {
|
||||
return;
|
||||
}
|
||||
|
||||
const weekdayFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||
weekday: 'long',
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
function parseDate(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!value) return null;
|
||||
const parts = value.split('-').map(Number);
|
||||
if (parts.length !== 3 || parts.some(Number.isNaN)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parts.length !== 3 || parts.some(Number.isNaN)) return null;
|
||||
return new Date(parts[0], parts[1] - 1, parts[2]);
|
||||
}
|
||||
|
||||
@@ -161,16 +177,92 @@
|
||||
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() {
|
||||
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 startTime = row.querySelector('[data-time-start]')?.value || '';
|
||||
const endTime = row.querySelector('[data-time-end]')?.value || '';
|
||||
return `${dayValue} ${startTime}-${endTime}`.trim();
|
||||
}).filter(Boolean);
|
||||
const pauseStart = row.querySelector('[data-time-pause-start]')?.value || '';
|
||||
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');
|
||||
calculateSlots(); // Nach jedem Update neu berechnen
|
||||
}
|
||||
|
||||
function renderRows() {
|
||||
@@ -193,40 +285,57 @@
|
||||
|
||||
endDateInput.setCustomValidity('');
|
||||
|
||||
// Bisherige Werte sichern
|
||||
const existingValues = new Map();
|
||||
Array.from(daysContainer.querySelectorAll('[data-day-row]')).forEach(function (row) {
|
||||
const dayValue = row.getAttribute('data-day-value');
|
||||
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 (dayValue) {
|
||||
existingValues.set(dayValue, { startTime, endTime });
|
||||
existingValues.set(dayValue, { startTime, endTime, pauseStart, pauseEnd });
|
||||
}
|
||||
});
|
||||
|
||||
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 = [];
|
||||
|
||||
for (let current = startDate; current <= endDate; current = addDays(current, 1)) {
|
||||
const dayValue = formatDateForValue(current);
|
||||
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(`
|
||||
<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="col-12 col-lg-5">
|
||||
<div class="col-12 col-lg-4">
|
||||
<label class="form-label fw-semibold mb-1">${formatDateForRow(current)}</label>
|
||||
<div class="text-muted small">${dayValue}</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<label class="form-label mb-1">Von</label>
|
||||
<input type="time" class="form-control" value="${rowStart}" data-time-start>
|
||||
<div class="col-6 col-lg-2">
|
||||
<label class="form-label mb-1 text-muted small">Von</label>
|
||||
<input type="time" class="form-control form-control-sm" value="${rowStart}" data-time-start>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<label class="form-label mb-1">Bis</label>
|
||||
<input type="time" class="form-control" value="${rowEnd}" data-time-end>
|
||||
<div class="col-6 col-lg-2">
|
||||
<label class="form-label mb-1 text-muted small">Bis</label>
|
||||
<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>
|
||||
@@ -242,17 +351,25 @@
|
||||
syncTextarea();
|
||||
}
|
||||
|
||||
// Event Listeners Registration
|
||||
buildButton.addEventListener('click', renderRows);
|
||||
startDateInput.addEventListener('input', renderRows);
|
||||
startDateInput.addEventListener('change', renderRows);
|
||||
endDateInput.addEventListener('input', 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) {
|
||||
renderRows();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@@ -26,12 +26,6 @@
|
||||
</div>
|
||||
<button id="new-booking" class="primary-button">Zur Termin-Konfiguration</button>
|
||||
</div>
|
||||
<div class="calendar-legend">
|
||||
<span class="legend-item"><span class="legend-color current"></span> Aktuelle Termine</span>
|
||||
<span class="legend-item"><span class="legend-color planned"></span> Geplante Termine</span>
|
||||
<span class="legend-item"><span class="legend-color completed"></span> Abgeschlossene Termine</span>
|
||||
<span class="legend-item"><span class="legend-color your-bookings"></span> Ihre Termine</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="calendar-options">
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
<div class="border rounded-3 p-3">
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3">
|
||||
<div>
|
||||
<h3 class="h6 fw-bold mb-1">{{ event.title or "Terminplan" }}</h3>
|
||||
<div class="fw-semibold">{{ event.date_start }} bis {{ event.date_end }}</div>
|
||||
<div class="text-muted small">ID: {{ event.appointment_id }}</div>
|
||||
{% if event.time_span %}
|
||||
|
||||
+345
-292
@@ -155,6 +155,27 @@
|
||||
background-color: #1e7e34;
|
||||
}
|
||||
|
||||
/* Quagga Scanner Video Styles */
|
||||
#code4-scanner video, #code4-scanner canvas,
|
||||
#isbn-scanner video, #isbn-scanner canvas {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
height: auto;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
#code4-scanner canvas, #isbn-scanner canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
#code4-scanner, #isbn-scanner {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ISBN input group styling */
|
||||
.isbn-input-group {
|
||||
display: flex;
|
||||
@@ -767,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>
|
||||
@@ -831,7 +864,7 @@
|
||||
<!-- Options will be loaded by JavaScript -->
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="formupload_admin-group">
|
||||
<label for="filter2-4">Wert 4:</label>
|
||||
<select id="filter2-4" name="filter2" class="filter-dropdown-select">
|
||||
<option value="">-- Optional --</option>
|
||||
@@ -840,7 +873,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Thema:</h3>
|
||||
<h3>Schlagwort:</h3>
|
||||
<div class="multi-filter">
|
||||
<div class="form-group">
|
||||
<label for="filter3-1">Wert 1:</label>
|
||||
@@ -869,25 +902,41 @@
|
||||
<label for="anschaffungskosten">Anschaffungskosten (€)</label>
|
||||
<input id="anschaffungskosten" name="anschaffungskosten">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="code_4">Code</label>
|
||||
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
|
||||
<input id="code_4" name="code_4">
|
||||
<button type="button" id="scan-code4-btn" class="fetch-isbn-button">Barcode scannen</button>
|
||||
<div class="form-group">
|
||||
<label for="scan_mode">Erfassungs-Modus:</label>
|
||||
<select id="scan_mode" name="scan_mode" class="form-control" onchange="toggleScanMode()">
|
||||
<option value="single">Einzel-Code (Scanner stoppt nach 1 Scan)</option>
|
||||
<option value="continuous">Fortlaufend scannen (Mehrere Codes nacheinander)</option>
|
||||
<option value="range">Code-Bereich generieren (z.B. ABC-001 bis ABC-010)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="range_generator_group" class="form-group" style="display:none; background: #f9f9f9; padding: 15px; border-radius: 5px; border: 1px solid #ddd; margin-bottom: 15px;">
|
||||
<label style="font-weight: bold;">Code-Bereich automatisch generieren:</label>
|
||||
<div style="display: flex; gap: 10px; margin-bottom: 10px; align-items: center;">
|
||||
<input type="text" id="range_prefix" placeholder="Präfix (z.B. IT-)" class="form-control" style="flex: 2;">
|
||||
<input type="number" id="range_start" placeholder="Start (z.B. 1)" class="form-control" style="flex: 1;">
|
||||
<span style="font-weight: bold;">bis</span>
|
||||
<input type="text" id="range_end" placeholder="Ende (z.B. 020)" class="form-control" style="flex: 1;">
|
||||
</div>
|
||||
<small style="display:block; color:#666;">Einzelcode manuell setzen oder per Scanner erfassen.</small>
|
||||
<div id="code4-scanner" style="width:100%; max-width:520px; display:none; margin-top:10px;"></div>
|
||||
<small id="code4-scan-status" style="display:block; color:#666; margin-top:6px;"></small>
|
||||
<small style="display:block; color:#666; margin-bottom: 10px;">Tipp: Die Anzahl der Ziffern im "Ende"-Feld bestimmt die führenden Nullen (z.B. Ende "050" macht aus Start "1" einen "001").</small>
|
||||
<button type="button" class="btn btn-secondary" onclick="generateCodeRange()">Bereich generieren & einfügen</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="item_count">Anzahl dieser Sorte</label>
|
||||
<input type="number" id="item_count" name="item_count" min="1" max="100" value="1">
|
||||
<small style="display:block; color:#666;">Es werden eigene Items erstellt, aber in der Übersicht als ein Artikel gebündelt angezeigt.</small>
|
||||
|
||||
<div class="form-group" id="primary_code_group">
|
||||
<label for="code_4">Basis-Code</label>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<input type="text" id="code_4" name="code_4" class="form-control" placeholder="Haupt-Barcode" required>
|
||||
<button type="button" id="scan-code4-btn" class="btn btn-primary">Barcode scannen</button>
|
||||
</div>
|
||||
<div id="code4-scanner" style="display:none; margin-top: 10px;"></div>
|
||||
<small id="code4-scan-status" class="form-text text-muted"></small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="individual_codes">Einzelcodes (optional, je Zeile ein Code)</label>
|
||||
<textarea id="individual_codes" name="individual_codes" rows="4" placeholder="z.B.\nABC-001\nABC-002"></textarea>
|
||||
<small style="display:block; color:#666;">Bei Anzahl > 1 können hier individuelle Codes pro Item gesetzt werden. Der Scanner setzt immer zuerst den Basis-Code im Feld oben; weitere Einzelcodes können hier bei Bedarf manuell ergänzt werden.</small>
|
||||
|
||||
<div class="form-group" id="individual_codes_group">
|
||||
<label for="individual_codes">Weitere Einzelcodes (je Zeile ein Code)</label>
|
||||
<textarea id="individual_codes" name="individual_codes" rows="6" placeholder="z.B. ABC-001 ABC-002" class="form-control"></textarea>
|
||||
<small style="display:block; color:#666; margin-top: 5px;">Bei Anzahl > 1 können hier individuelle Codes pro Item gesetzt werden. Der Scanner setzt immer zuerst den Basis-Code im Feld oben; weitere Einzelcodes werden hier angehängt.</small>
|
||||
</div>
|
||||
<!-- Image upload (hidden for library mode) -->
|
||||
<div class="form-group" {% if show_library_features %}style="display:none;"{% endif %}>
|
||||
@@ -905,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>
|
||||
@@ -970,8 +1019,7 @@
|
||||
document.getElementById('csv-help-modal').style.display = 'none';
|
||||
}
|
||||
</script>
|
||||
|
||||
<script src="https://unpkg.com/html5-qrcode@2.0.9/dist/html5-qrcode.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
|
||||
<script>
|
||||
const libraryModuleEnabled = {{ 'true' if library_module_enabled else 'false' }};
|
||||
|
||||
@@ -984,12 +1032,12 @@
|
||||
|
||||
// Global variable to store fetched book data
|
||||
let currentBookData = null;
|
||||
let code4ScannerInstance = null;
|
||||
let code4ScannerRunning = false;
|
||||
let code4LastScanned = '';
|
||||
let code4LastScannedAt = 0;
|
||||
let isbnScannerInstance = null;
|
||||
let isbnScannerRunning = false;
|
||||
|
||||
// Quagga2 engine state management properties
|
||||
let scannerRunning = false;
|
||||
let activeScannerCallback = null; // Dynamically handles routing data to the active caller field
|
||||
var code4LastScanned = '';
|
||||
var code4LastScannedAt = 0;
|
||||
|
||||
function setCode4ScanStatus(message, isError = false) {
|
||||
const statusEl = document.getElementById('code4-scan-status');
|
||||
@@ -1042,61 +1090,230 @@
|
||||
statusEl.style.color = '#b00020';
|
||||
}
|
||||
|
||||
// Centered Quagga2 Engine Initialization Pattern
|
||||
function runEngineInitialization(targetSelector, activeCallback, completionMsg, errorStatusSetter) {
|
||||
if (scannerRunning) {
|
||||
Quagga.stop();
|
||||
scannerRunning = false;
|
||||
}
|
||||
|
||||
activeScannerCallback = activeCallback;
|
||||
|
||||
Quagga.init({
|
||||
inputStream: {
|
||||
name: "Live",
|
||||
type: "LiveStream",
|
||||
target: document.querySelector(targetSelector),
|
||||
constraints: {
|
||||
width: 640,
|
||||
height: 480,
|
||||
facingMode: "environment" // Force rear camera lenses
|
||||
},
|
||||
},
|
||||
decoder: {
|
||||
readers: ["code_128_reader", "ean_reader", "code_39_reader", "upc_reader"]
|
||||
}
|
||||
}, function(err) {
|
||||
if (err) {
|
||||
console.error("Initialization error:", err);
|
||||
errorStatusSetter("Kamera konnte nicht gestartet werden.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
Quagga.start();
|
||||
scannerRunning = true;
|
||||
errorStatusSetter(completionMsg, false);
|
||||
});
|
||||
}
|
||||
|
||||
function killScannerHardware() {
|
||||
if (!scannerRunning) return;
|
||||
Quagga.stop();
|
||||
scannerRunning = false;
|
||||
activeScannerCallback = null;
|
||||
}
|
||||
|
||||
// Unified tracking capture listener
|
||||
Quagga.onDetected(function(data) {
|
||||
if (!data || !data.codeResult || !data.codeResult.code) return;
|
||||
|
||||
const rawBarcode = String(data.codeResult.code || '').trim();
|
||||
const currentTargetCallback = activeScannerCallback;
|
||||
|
||||
if (typeof currentTargetCallback === "function") {
|
||||
currentTargetCallback(rawBarcode);
|
||||
}
|
||||
});
|
||||
|
||||
// Globale Variablen für den Scanner-Status
|
||||
var code4LastScanned = '';
|
||||
var code4LastScannedAt = 0;
|
||||
|
||||
/**
|
||||
* Schaltet das UI basierend auf dem ausgewählten Modus um
|
||||
*/
|
||||
function toggleScanMode() {
|
||||
const mode = document.getElementById('scan_mode').value;
|
||||
const rangeGroup = document.getElementById('range_generator_group');
|
||||
const scanButton = document.getElementById('scan-code4-btn');
|
||||
const scannerBox = document.getElementById('code4-scanner');
|
||||
|
||||
if (scannerBox && scannerBox.style.display !== 'none') {
|
||||
if (typeof killScannerHardware === 'function') killScannerHardware();
|
||||
scannerBox.style.display = 'none';
|
||||
if (scanButton) scanButton.textContent = 'Barcode scannen';
|
||||
}
|
||||
|
||||
if (mode === 'range') {
|
||||
if(rangeGroup) rangeGroup.style.display = 'block';
|
||||
if(scanButton) scanButton.style.display = 'none';
|
||||
} else {
|
||||
if(rangeGroup) rangeGroup.style.display = 'none'; // Hier wurde der Fehler korrigiert
|
||||
if(scanButton) scanButton.style.display = 'inline-block';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert eine Liste von Codes basierend auf den Eingaben
|
||||
*/
|
||||
function generateCodeRange() {
|
||||
const prefix = document.getElementById('range_prefix').value.trim();
|
||||
const startStr = document.getElementById('range_start').value.trim();
|
||||
const endStr = document.getElementById('range_end').value.trim();
|
||||
|
||||
const start = parseInt(startStr, 10);
|
||||
const end = parseInt(endStr, 10);
|
||||
|
||||
const paddingLength = endStr.length;
|
||||
|
||||
if (isNaN(start) || isNaN(end) || start > end) {
|
||||
alert("Bitte gültige Zahlen für Start und Ende eingeben. Der Startwert darf nicht größer als der Endwert sein.");
|
||||
return;
|
||||
}
|
||||
|
||||
let generatedCodes = [];
|
||||
for (let i = start; i <= end; i++) {
|
||||
let numStr = i.toString().padStart(paddingLength, '0');
|
||||
generatedCodes.push(`${prefix}${numStr}`);
|
||||
}
|
||||
|
||||
const codeField = document.getElementById('code_4');
|
||||
const individualCodesArea = document.getElementById('individual_codes');
|
||||
|
||||
if (generatedCodes.length > 0) {
|
||||
if (!codeField.value || confirm("Soll der aktuelle Basis-Code überschrieben werden?")) {
|
||||
codeField.value = generatedCodes[0];
|
||||
if (typeof validateCodeField === 'function') validateCodeField(codeField);
|
||||
generatedCodes.shift();
|
||||
}
|
||||
|
||||
if (generatedCodes.length > 0) {
|
||||
let existing = individualCodesArea.value.trim();
|
||||
individualCodesArea.value = existing ? existing + '\n' + generatedCodes.join('\n') : generatedCodes.join('\n');
|
||||
}
|
||||
|
||||
alert(`Bereich erfolgreich generiert! (${end - start + 1} Codes)`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scanner-Funktion (Single & Continuous Modus)
|
||||
*/
|
||||
function startCode4Scanner() {
|
||||
const scannerBox = document.getElementById('code4-scanner');
|
||||
const scanButton = document.getElementById('scan-code4-btn');
|
||||
const codeField = document.getElementById('code_4');
|
||||
const individualCodesArea = document.getElementById('individual_codes');
|
||||
const scanMode = document.getElementById('scan_mode').value;
|
||||
|
||||
if (!scannerBox || !scanButton || !codeField) return;
|
||||
|
||||
// Stop ISBN scanner if currently running to avoid camera conflicts.
|
||||
if (isbnScannerRunning && isbnScannerInstance) {
|
||||
isbnScannerInstance.clear().catch(() => {});
|
||||
const isbnScannerBox = document.getElementById('isbn-scanner');
|
||||
// Beende evtl. laufenden ISBN Scanner
|
||||
const isbnScannerBox = document.getElementById('isbn-scanner');
|
||||
if (isbnScannerBox && isbnScannerBox.style.display !== 'none') {
|
||||
killScannerHardware();
|
||||
isbnScannerBox.style.display = 'none';
|
||||
const isbnScanButton = document.getElementById('scan-isbn-btn');
|
||||
if (isbnScannerBox) isbnScannerBox.style.display = 'none';
|
||||
if (isbnScanButton) isbnScanButton.textContent = 'ISBN scannen';
|
||||
isbnScannerRunning = false;
|
||||
if (isbnScanButton) isbnScanButton.textContent = 'Barcode scannen';
|
||||
if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus('Scanner gestoppt.');
|
||||
}
|
||||
|
||||
if (code4ScannerRunning && code4ScannerInstance) {
|
||||
code4ScannerInstance.clear().catch(() => {});
|
||||
// Scanner ausschalten, wenn er bereits läuft
|
||||
if (scannerBox.style.display !== 'none') {
|
||||
killScannerHardware();
|
||||
scannerBox.style.display = 'none';
|
||||
scanButton.textContent = 'Barcode scannen';
|
||||
code4ScannerRunning = false;
|
||||
setCode4ScanStatus('Scanner gestoppt.');
|
||||
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus('Scanner gestoppt.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Scanner starten
|
||||
scannerBox.style.display = 'block';
|
||||
scanButton.textContent = 'Scanner stoppen';
|
||||
code4ScannerInstance = new Html5QrcodeScanner('code4-scanner', {
|
||||
fps: 10,
|
||||
qrbox: 250,
|
||||
rememberLastUsedCamera: true
|
||||
});
|
||||
code4ScannerRunning = true;
|
||||
setCode4ScanStatus('Scanner läuft. Der Scan wird im Basis-Codefeld übernommen.');
|
||||
scanButton.textContent = scanMode === 'continuous' ? 'Fortlaufenden Scan stoppen' : 'Scanner stoppen';
|
||||
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus('Scanner initialisiert. Richte die Kamera auf den Barcode...');
|
||||
|
||||
code4ScannerInstance.render((decodedText) => {
|
||||
const scannedCode = String(decodedText || '').trim();
|
||||
if (!scannedCode) return;
|
||||
if (typeof runEngineInitialization === 'function') {
|
||||
runEngineInitialization(
|
||||
'#code4-scanner',
|
||||
function(decodedText) {
|
||||
const now = Date.now();
|
||||
if (decodedText === code4LastScanned && (now - code4LastScannedAt) < 1500) {
|
||||
return;
|
||||
}
|
||||
code4LastScanned = decodedText;
|
||||
code4LastScannedAt = now;
|
||||
|
||||
if (scanMode === 'single') {
|
||||
killScannerHardware();
|
||||
scannerBox.style.display = 'none';
|
||||
scanButton.textContent = 'Barcode scannen';
|
||||
|
||||
codeField.value = decodedText;
|
||||
if (typeof validateCodeField === 'function') validateCodeField(codeField);
|
||||
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus(`Code gesetzt: ${decodedText}`);
|
||||
|
||||
const now = Date.now();
|
||||
if (scannedCode === code4LastScanned && (now - code4LastScannedAt) < 1500) {
|
||||
return;
|
||||
}
|
||||
code4LastScanned = scannedCode;
|
||||
code4LastScannedAt = now;
|
||||
} else if (scanMode === 'continuous') {
|
||||
let currentAreaCodes = individualCodesArea.value.split('\n').map(c => c.trim()).filter(c => c !== '');
|
||||
|
||||
codeField.value = scannedCode;
|
||||
validateCodeField(codeField);
|
||||
setCode4ScanStatus(`Code_4 gesetzt: ${scannedCode}`);
|
||||
}, () => {});
|
||||
if (!codeField.value) {
|
||||
codeField.value = decodedText;
|
||||
if (typeof validateCodeField === 'function') validateCodeField(codeField);
|
||||
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus(`Basis-Code gesetzt: ${decodedText}`);
|
||||
} else if (!currentAreaCodes.includes(decodedText) && codeField.value !== decodedText) {
|
||||
individualCodesArea.value += (individualCodesArea.value ? '\n' : '') + decodedText;
|
||||
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus(`Zusatz-Code erfasst: ${decodedText}`);
|
||||
|
||||
try {
|
||||
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const oscillator = audioCtx.createOscillator();
|
||||
const gainNode = audioCtx.createGain();
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioCtx.destination);
|
||||
oscillator.type = 'sine';
|
||||
oscillator.frequency.value = 800;
|
||||
gainNode.gain.setValueAtTime(0.1, audioCtx.currentTime);
|
||||
oscillator.start();
|
||||
oscillator.stop(audioCtx.currentTime + 0.1);
|
||||
} catch (e) {
|
||||
console.log("Audio feedback not supported");
|
||||
}
|
||||
} else {
|
||||
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus(`Code ${decodedText} wurde bereits gescannt.`);
|
||||
}
|
||||
}
|
||||
},
|
||||
'Scanner läuft. Richte die Kamera auf den Barcode.',
|
||||
setCode4ScanStatus
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ISBN Scanner-Funktion
|
||||
*/
|
||||
function startIsbnScanner() {
|
||||
if (!libraryModuleEnabled) {
|
||||
setIsbnScanStatus('Bibliotheks-Modul ist deaktiviert.', true);
|
||||
if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus('Bibliotheks-Modul ist deaktiviert.', true);
|
||||
return;
|
||||
}
|
||||
const scannerBox = document.getElementById('isbn-scanner');
|
||||
@@ -1104,61 +1321,75 @@
|
||||
const isbnField = document.getElementById('isbn');
|
||||
if (!scannerBox || !scanButton || !isbnField) return;
|
||||
|
||||
// Stop Code_4 scanner if currently running to avoid camera conflicts.
|
||||
if (code4ScannerRunning && code4ScannerInstance) {
|
||||
code4ScannerInstance.clear().catch(() => {});
|
||||
const codeScannerBox = document.getElementById('code4-scanner');
|
||||
const codeScanButton = document.getElementById('scan-code4-btn');
|
||||
if (codeScannerBox) codeScannerBox.style.display = 'none';
|
||||
if (codeScanButton) codeScanButton.textContent = 'Barcode scannen';
|
||||
code4ScannerRunning = false;
|
||||
setCode4ScanStatus('Scanner gestoppt.');
|
||||
const codeScannerBox = document.getElementById('code4-scanner');
|
||||
const codeScanButton = document.getElementById('scan-code4-btn');
|
||||
if (codeScannerBox && codeScannerBox.style.display !== 'none') {
|
||||
killScannerHardware();
|
||||
codeScannerBox.style.display = 'none';
|
||||
codeScanButton.textContent = 'Barcode scannen';
|
||||
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus('Scanner gestoppt.');
|
||||
}
|
||||
|
||||
if (isbnScannerRunning && isbnScannerInstance) {
|
||||
isbnScannerInstance.clear().catch(() => {});
|
||||
if (scannerBox.style.display !== 'none') {
|
||||
killScannerHardware();
|
||||
scannerBox.style.display = 'none';
|
||||
scanButton.textContent = 'ISBN scannen';
|
||||
isbnScannerRunning = false;
|
||||
setIsbnScanStatus('Scanner gestoppt.');
|
||||
scanButton.textContent = 'Barcode scannen';
|
||||
if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus('Scanner gestoppt.');
|
||||
return;
|
||||
}
|
||||
|
||||
scannerBox.style.display = 'block';
|
||||
scanButton.textContent = 'Scanner stoppen';
|
||||
isbnScannerInstance = new Html5QrcodeScanner('isbn-scanner', {
|
||||
fps: 10,
|
||||
qrbox: 250,
|
||||
rememberLastUsedCamera: true
|
||||
});
|
||||
isbnScannerRunning = true;
|
||||
setIsbnScanStatus('Scanner läuft. Bitte ISBN-Code erfassen.');
|
||||
|
||||
isbnScannerInstance.render((decodedText) => {
|
||||
const scannedCode = String(decodedText || '').trim();
|
||||
if (!scannedCode) return;
|
||||
runEngineInitialization(
|
||||
'#isbn-scanner',
|
||||
function(decodedText) {
|
||||
killScannerHardware();
|
||||
scannerBox.style.display = 'none';
|
||||
scanButton.textContent = 'Barcode scannen';
|
||||
|
||||
const normalizedIsbn = normalizeIsbnClient(scannedCode);
|
||||
isbnField.value = normalizedIsbn || scannedCode;
|
||||
updateIsbnLiveValidation();
|
||||
if (normalizedIsbn) {
|
||||
setIsbnScanStatus(`ISBN gesetzt: ${normalizedIsbn}`);
|
||||
} else {
|
||||
setIsbnScanStatus('Kein gültiges ISBN-Format erkannt, der gescannte Wert bleibt aber im Feld.', true);
|
||||
}
|
||||
const normalizedIsbn = typeof normalizeIsbnClient === 'function' ? normalizeIsbnClient(decodedText) : decodedText;
|
||||
isbnField.value = normalizedIsbn || decodedText;
|
||||
if (typeof updateIsbnLiveValidation === 'function') updateIsbnLiveValidation();
|
||||
|
||||
isbnScannerInstance.clear().catch(() => {});
|
||||
scannerBox.style.display = 'none';
|
||||
scanButton.textContent = 'ISBN scannen';
|
||||
isbnScannerRunning = false;
|
||||
|
||||
if (normalizedIsbn) {
|
||||
// Automatically load book metadata after a valid ISBN scan.
|
||||
fetchBookInfo('upload');
|
||||
}
|
||||
}, () => {});
|
||||
if (normalizedIsbn) {
|
||||
if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus(`ISBN gesetzt: ${normalizedIsbn}`);
|
||||
if (typeof fetchBookInfo === 'function') fetchBookInfo('upload');
|
||||
} else {
|
||||
if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus('Kein gültiges ISBN-Format erkannt, der gescannte Wert bleibt aber im Feld.', true);
|
||||
}
|
||||
},
|
||||
'Scanner läuft. Bitte ISBN-Code erfassen.',
|
||||
setIsbnScanStatus
|
||||
);
|
||||
}
|
||||
|
||||
// --- WICHTIG: Event Listener Binden sobald DOM geladen ist ---
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 1. Basis-Code Scanner Button aktivieren
|
||||
const scanCode4Btn = document.getElementById('scan-code4-btn');
|
||||
if (scanCode4Btn) {
|
||||
scanCode4Btn.addEventListener('click', startCode4Scanner);
|
||||
}
|
||||
|
||||
// 2. ISBN Scanner Button aktivieren
|
||||
const scanIsbnBtn = document.getElementById('scan-isbn-btn');
|
||||
if (scanIsbnBtn) {
|
||||
scanIsbnBtn.addEventListener('click', startIsbnScanner);
|
||||
}
|
||||
|
||||
// 3. Dropdown-Modus Wechsel überwachen
|
||||
const scanModeSelect = document.getElementById('scan_mode');
|
||||
if (scanModeSelect) {
|
||||
scanModeSelect.addEventListener('change', toggleScanMode);
|
||||
}
|
||||
|
||||
// 4. ISBN Live-Validierung
|
||||
const isbnInput = document.getElementById('isbn');
|
||||
if (isbnInput && typeof updateIsbnLiveValidation === 'function') {
|
||||
isbnInput.addEventListener('input', updateIsbnLiveValidation);
|
||||
}
|
||||
});
|
||||
// Load predefined filter values for dropdowns
|
||||
function loadPredefinedFilterValues(filterNumber) {
|
||||
fetch(`/get_predefined_filter_values/${filterNumber}`)
|
||||
@@ -1344,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.');
|
||||
@@ -1381,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);
|
||||
@@ -1558,6 +1797,7 @@
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
// Exported function for use within parent layout fields
|
||||
function validateCodeField(input, excludeId = null) {
|
||||
const code = input.value.trim();
|
||||
|
||||
@@ -1683,16 +1923,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
const scanCodeBtn = document.getElementById('scan-code4-btn');
|
||||
if (scanCodeBtn) {
|
||||
scanCodeBtn.addEventListener('click', startCode4Scanner);
|
||||
}
|
||||
|
||||
const scanIsbnBtn = document.getElementById('scan-isbn-btn');
|
||||
if (scanIsbnBtn) {
|
||||
scanIsbnBtn.addEventListener('click', startIsbnScanner);
|
||||
}
|
||||
|
||||
const isbnField = document.getElementById('isbn');
|
||||
if (isbnField) {
|
||||
isbnField.addEventListener('input', updateIsbnLiveValidation);
|
||||
@@ -1732,188 +1962,11 @@
|
||||
if (typeof duplicateData.images === 'string') {
|
||||
try {
|
||||
duplicateData.images = JSON.parse(duplicateData.images);
|
||||
console.log("Parsed images from string:", duplicateData.images);
|
||||
} catch (e) {
|
||||
console.warn("Failed to parse images string, using as single item:", duplicateData.images);
|
||||
duplicateData.images = [duplicateData.images];
|
||||
console.error("Error parsing duplicate images array:", e);
|
||||
}
|
||||
}
|
||||
console.log(`Final duplicateData.images (${Array.isArray(duplicateData.images) ? 'array' : typeof duplicateData.images}):`, duplicateData.images);
|
||||
}
|
||||
}
|
||||
|
||||
// Then check for sessionStorage data (takes priority)
|
||||
const sessionData = sessionStorage.getItem('duplicateItemData');
|
||||
if (sessionData) {
|
||||
try {
|
||||
duplicateData = JSON.parse(sessionData);
|
||||
sessionStorage.removeItem('duplicateItemData'); // Clean up
|
||||
console.log("Session storage duplicate data:", duplicateData);
|
||||
} catch (e) {
|
||||
console.error('Error parsing session duplicate data:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Function to prefill form with duplicate data
|
||||
function prefillFormWithDuplicateData() {
|
||||
if (!duplicateData) return;
|
||||
|
||||
// Fill basic fields
|
||||
const nameField = document.getElementById('name');
|
||||
if (nameField && duplicateData.name) nameField.value = duplicateData.name;
|
||||
|
||||
const descField = document.getElementById('beschreibung');
|
||||
if (descField && duplicateData.description) descField.value = duplicateData.description;
|
||||
|
||||
const yearField = document.getElementById('anschaffungsjahr');
|
||||
if (yearField && duplicateData.year) yearField.value = duplicateData.year;
|
||||
|
||||
const costField = document.getElementById('anschaffungskosten');
|
||||
if (costField && duplicateData.cost) costField.value = duplicateData.cost;
|
||||
|
||||
// Fill location dropdown
|
||||
const locationField = document.getElementById('ort');
|
||||
if (locationField && duplicateData.location) {
|
||||
// Wait for locations to load, then set value
|
||||
setTimeout(() => {
|
||||
locationField.value = duplicateData.location;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Fill filter arrays - handle up to 4 values per filter type
|
||||
setTimeout(() => {
|
||||
// Filter 1 (Unterrichtsfach)
|
||||
if (duplicateData.filter1 && Array.isArray(duplicateData.filter1)) {
|
||||
for (let j = 1; j <= Math.min(4, duplicateData.filter1.length); j++) {
|
||||
const filterField = document.getElementById(`filter1-${j}`);
|
||||
if (filterField && duplicateData.filter1[j-1]) {
|
||||
filterField.value = duplicateData.filter1[j-1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter 2 (Jahrgangsstufe)
|
||||
if (duplicateData.filter2 && Array.isArray(duplicateData.filter2)) {
|
||||
for (let j = 1; j <= Math.min(4, duplicateData.filter2.length); j++) {
|
||||
const filterField = document.getElementById(`filter2-${j}`);
|
||||
if (filterField && duplicateData.filter2[j-1]) {
|
||||
filterField.value = duplicateData.filter2[j-1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter 3 (Thema) - text inputs
|
||||
if (duplicateData.filter3 && Array.isArray(duplicateData.filter3)) {
|
||||
for (let j = 1; j <= Math.min(4, duplicateData.filter3.length); j++) {
|
||||
const filterField = document.getElementById(`filter3-${j}`);
|
||||
if (filterField && duplicateData.filter3[j-1]) {
|
||||
filterField.value = duplicateData.filter3[j-1];
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 200); // Wait for filter options to load
|
||||
|
||||
// Handle images - add hidden inputs for duplicate images
|
||||
if (duplicateData.images && duplicateData.images.length > 0) {
|
||||
console.log("Duplicate images data:", duplicateData.images);
|
||||
const form = document.querySelector('form[action*="upload_item"]');
|
||||
if (form) {
|
||||
// Add hidden field to indicate duplication
|
||||
const isDuplicatingField = document.createElement('input');
|
||||
isDuplicatingField.type = 'hidden';
|
||||
isDuplicatingField.name = 'is_duplicating';
|
||||
isDuplicatingField.value = 'true';
|
||||
form.appendChild(isDuplicatingField);
|
||||
|
||||
// Add each image as a duplicate image
|
||||
const images = duplicateData.images || [];
|
||||
console.log(`Adding ${images.length} images as hidden fields`);
|
||||
|
||||
if (Array.isArray(images)) {
|
||||
images.forEach((imageName, index) => {
|
||||
if (imageName) {
|
||||
console.log(`Adding duplicate image ${index}: ${imageName}`);
|
||||
const imageField = document.createElement('input');
|
||||
imageField.type = 'hidden';
|
||||
imageField.name = 'duplicate_images';
|
||||
imageField.value = imageName;
|
||||
form.appendChild(imageField);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.warn("Images is not an array:", images);
|
||||
// Try to handle as single item if it's a string
|
||||
if (typeof images === 'string' && images) {
|
||||
console.log("Adding single image:", images);
|
||||
const imageField = document.createElement('input');
|
||||
imageField.type = 'hidden';
|
||||
imageField.name = 'duplicate_images';
|
||||
imageField.value = images;
|
||||
form.appendChild(imageField);
|
||||
}
|
||||
}
|
||||
|
||||
// Show image preview
|
||||
showDuplicateImagePreview(duplicateData.images);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Function to show preview of duplicate images
|
||||
function showDuplicateImagePreview(images) {
|
||||
const previewContainer = document.getElementById('image-preview-container');
|
||||
if (!previewContainer) return;
|
||||
|
||||
console.log("showDuplicateImagePreview called with:", images);
|
||||
|
||||
// Make sure images is treated as an array
|
||||
if (!Array.isArray(images)) {
|
||||
if (typeof images === 'string') {
|
||||
// Try to parse if it's a JSON string
|
||||
try {
|
||||
images = JSON.parse(images);
|
||||
} catch (e) {
|
||||
// If parsing fails, wrap the single string in an array
|
||||
images = [images];
|
||||
}
|
||||
} else {
|
||||
console.error("Invalid images data:", images);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
previewContainer.innerHTML = '';
|
||||
console.log(`Processing ${images.length} images for preview`);
|
||||
|
||||
images.forEach((imageName, index) => {
|
||||
console.log(`Processing image ${index}:`, imageName);
|
||||
const preview = document.createElement('div');
|
||||
preview.className = 'image-preview-item';
|
||||
preview.innerHTML = `
|
||||
<img src="/uploads/${imageName}" alt="Duplicate Image ${index + 1}" class="duplicate-image-preview">
|
||||
<div class="image-controls">
|
||||
<span>Von Original übernommen</span>
|
||||
<button type="button" onclick="removeDuplicateImage('${imageName}', this)" class="remove-duplicate-image-button">Entfernen</button>
|
||||
</div>
|
||||
`;
|
||||
previewContainer.appendChild(preview);
|
||||
});
|
||||
}
|
||||
|
||||
// Function to remove a duplicate image
|
||||
function removeDuplicateImage(imageName, button) {
|
||||
// Remove the hidden input
|
||||
const hiddenInputs = document.querySelectorAll('input[name="duplicate_images"]');
|
||||
hiddenInputs.forEach(input => {
|
||||
if (input.value === imageName) {
|
||||
input.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Remove the preview
|
||||
const previewItem = button.closest('.image-preview-item');
|
||||
if (previewItem) {
|
||||
previewItem.remove();
|
||||
console.log("Parsed duplicate images successfully.");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -55,6 +55,7 @@ services:
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
environment:
|
||||
MONGO_INITDB_DATABASE: inventar_default
|
||||
|
||||
|
||||
+109
-136
@@ -23,70 +23,19 @@ ensure_runtime_config_json() {
|
||||
echo "Warning: moved unexpected directory $config_path to $backup_path"
|
||||
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,8 @@ 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
|
||||
|
||||
@@ -244,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
|
||||
@@ -257,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,
|
||||
@@ -293,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",
|
||||
@@ -318,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() {
|
||||
@@ -434,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")" )
|
||||
@@ -445,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" )
|
||||
@@ -455,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
|
||||
@@ -565,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."
|
||||
@@ -597,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
|
||||
@@ -690,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
|
||||
@@ -712,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)
|
||||
@@ -756,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."
|
||||
@@ -832,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'
|
||||
@@ -913,4 +886,4 @@ PY
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
exit 0
|
||||
@@ -15,3 +15,4 @@ openpyxl
|
||||
cryptography>=42.0.0
|
||||
pywebpush
|
||||
py-vapid>=1.9.0
|
||||
beautifulsoup4
|
||||
Reference in New Issue
Block a user