Compare commits

...

21 Commits

Author SHA1 Message Date
Aiirondev_dev d746b61a17 feat: integrate Quagga2 scanner, background pagination, and secure fetch API
Release Inventarsystem / release-docker (push) Successful in 2m15s
Configure Quagga2 barcode scanner with environment facing mode
Add fallback keyboardScanKeydownHandler for physical keyboard-wedge scanners
Secure fetch requests by including X-CSRFToken headers
Implement loadRemainingLibraryItemsInBackground for seamless data loading
Refactor showItemDetail to accurately render videos, external URLs, and local uploa
2026-08-14 22:33:52 +02:00
Aiirondev_dev 440cafb88f Merge remote-tracking branch 'origin/main'
Release Inventarsystem / release-docker (push) Successful in 2m18s
2026-08-14 20:35:44 +02:00
Aiirondev_dev 9fb56e905c Renaming of the Schülerausweis to the Bibliotheksausweis 2026-08-14 20:35:31 +02:00
Aiirondev_dev 21ad639081 changes to make the default redirect endpoint in chase of page authentication error
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-14 20:20:59 +02:00
Aiirondev_dev 61d726f8f4 Final fixes of the user functions to remove the breaking encryption of the username.
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-14 14:17:54 +02:00
Aiirondev_dev 2d7cec075c workaround for existing misused users
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-14 12:08:31 +02:00
Aiirondev_dev fc0fdf8472 change to the authentification system to allow the user acces o the main page
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-14 12:05:23 +02:00
Aiirondev_dev 237c79be58 changes to the user authentication
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-14 11:58:44 +02:00
Aiirondev_dev 821636908f changes to the user authentification
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-14 11:51:10 +02:00
Aiirondev_dev 1506406adc Merge remote-tracking branch 'refs/remotes/origin/main'
Release Inventarsystem / release-docker (push) Successful in 3m18s
2026-08-14 11:24:51 +02:00
Aiirondev_dev 567edc43a7 Slight changes to make the username appear correctly 2026-08-14 11:15:12 +02:00
Aiirondev_dev 28f5991fa0 slight fixes to fix the image displayment
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-13 00:23:54 +02:00
Aiirondev_dev e9b4cf0c25 slight fixes to fix the image processing
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-12 23:47:38 +02:00
Aiirondev_dev 99ec28f329 Introduction of the images to the detailed view to the library_table.html
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-12 23:10:06 +02:00
Aiirondev_dev 057c517515 Debug removal of whitelist
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-12 22:38:07 +02:00
Aiirondev_dev cfec33b362 Slight clean up of old functions that are not in use anymore, and inclution of Image uploading for the library uploading process
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-12 22:29:45 +02:00
Aiirondev_dev fb29fb91a9 Fix for the delete user function
Release Inventarsystem / release-docker (push) Successful in 2m27s
2026-08-12 16:34:01 +02:00
Aiirondev_dev c3db2d9c6b Fix for the delete user function
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-12 16:31:17 +02:00
Aiirondev_dev edc7b72a8f Fix for the delete user function
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-12 16:26:08 +02:00
Aiirondev_dev cdf7b9c45d slight correction to also account for name and last_name
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-12 16:09:52 +02:00
Aiirondev_dev 5ba0944faf implementation of the user .csv importing
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-12 15:46:31 +02:00
9 changed files with 1097 additions and 2633 deletions
+341 -228
View File
@@ -27,6 +27,10 @@ import os
import sys import sys
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from gridfs import GridFS from gridfs import GridFS
import string
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
from reportlab.lib import colors
# Ensure imports work regardless of whether gunicorn starts in /app or /app/Web. # Ensure imports work regardless of whether gunicorn starts in /app or /app/Web.
_CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) _CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -449,7 +453,7 @@ PERMISSION_ACTION_ENDPOINTS = {
'admin_reset_user_password': 'can_manage_users', 'admin_reset_user_password': 'can_manage_users',
'admin_update_user_permissions': 'can_manage_users', 'admin_update_user_permissions': 'can_manage_users',
'admin_anonymize_names': 'can_manage_users', 'admin_anonymize_names': 'can_manage_users',
'home_admin': 'can_manage_settings', 'home_admin': 'can_borrow',
'upload_admin': 'can_insert', 'upload_admin': 'can_insert',
'library_admin': 'can_insert', 'library_admin': 'can_insert',
'admin_borrowings': 'can_manage_settings', 'admin_borrowings': 'can_manage_settings',
@@ -462,15 +466,36 @@ PERMISSION_ACTION_ENDPOINTS = {
} }
ALLOWED_COVER_DOMAINS = { ALLOWED_COVER_DOMAINS = {
# --- Google / Open APIs ---
"books.google.com", "books.google.com",
"covers.openlibrary.org", "www.googleapis.com",
"images-na.ssl-images-amazon.com",
"m.media-amazon.com", # --- Open Library / Internet Archive ---
"www.isbn.de",
"covers.openlibrary.org", "covers.openlibrary.org",
"openlibrary.org", "openlibrary.org",
# --- Amazon / Goodreads ---
"images-na.ssl-images-amazon.com",
"m.media-amazon.com",
"i.gr-assets.com", # Goodreads image CDN
# --- Library / Catalog Services ---
"www.isbn.de",
"lobid.org", "lobid.org",
"www.googleapis.com" "syndetics.com", # Standard cover provider for libraries
"pics.librarything.com", # LibraryThing covers
"portal.dnb.de", # Deutsche Nationalbibliothek
# --- German Educational & International Publishers ---
"www.westermann.de",
"www.klett.de", # Ernst Klett Verlag
"medien.klett.de", # Klett media CDN
"www.cornelsen.de", # Cornelsen Verlag
"images.penguinrandomhouse.com", # Penguin Random House
# --- Book Retailer CDNs (often used for cover fetching) ---
"images.thalia.media", # Thalia
"bilder.buecher.de" # buecher.de
} }
SENSITIVE_AUDIT_FIELDS = ["email", "username", "full_name", "phone", "borrower", "ip"] SENSITIVE_AUDIT_FIELDS = ["email", "username", "full_name", "phone", "borrower", "ip"]
@@ -786,6 +811,8 @@ def _page_access_allowed(permissions, endpoint):
if not permissions or not endpoint: if not permissions or not endpoint:
return True return True
page_permissions = permissions.get('pages', {}) page_permissions = permissions.get('pages', {})
if endpoint == "home_admin":
return True
return bool(page_permissions.get(endpoint, True)) return bool(page_permissions.get(endpoint, True))
@@ -798,14 +825,10 @@ def _action_access_allowed(permissions, action_key):
def _permission_denied_fallback_endpoint(permissions, current_endpoint=None): def _permission_denied_fallback_endpoint(permissions, current_endpoint=None):
username = session.get('username') username = session.get('username')
is_admin_user = bool(username and us.check_admin(username))
admin_home_allowed = _page_access_allowed(permissions, 'home_admin') and _action_access_allowed(permissions, 'can_manage_settings')
for candidate in ('my_borrowed_items', 'tutorial_page', 'notifications_view', 'impressum', 'home_admin'): for candidate in ('home_admin', 'my_borrowed_items', 'tutorial_page', 'notifications_view', 'impressum'):
if current_endpoint and candidate == current_endpoint: if current_endpoint and candidate == current_endpoint:
continue continue
if candidate == 'home_admin' and is_admin_user and not admin_home_allowed:
continue
if _page_access_allowed(permissions, candidate): if _page_access_allowed(permissions, candidate):
return candidate return candidate
return 'logout' return 'logout'
@@ -2636,6 +2659,12 @@ def upload_student_cards_excel():
"""Bulk import student cards from Excel.""" """Bulk import student cards from Excel."""
return _upload_student_cards_excel() return _upload_student_cards_excel()
@app.route('/api/is_student_card/<id>', methods=['POST'])
def is_student_card(id):
user = sanitize_form_value(id)
exists = bool(us.student_card_exists(user))
return jsonify({"is_student_card": exists})
"""-------------------------------------------------------------File Serving-----------------------------------------------------------------------------""" """-------------------------------------------------------------File Serving-----------------------------------------------------------------------------"""
@@ -4347,7 +4376,7 @@ def student_card_barcode_download():
# "SCHÜLERAUSWEIS" text in header # "SCHÜLERAUSWEIS" text in header
c.setFont("Helvetica-Bold", 9) c.setFont("Helvetica-Bold", 9)
c.setFillColor(white) c.setFillColor(white)
c.drawString(x_pos + 3*mm, y_pos - 6.5*mm, f"SCHÜLERAUSWEIS - {str(school_name)}") c.drawString(x_pos + 3*mm, y_pos - 6.5*mm, f"BIBLIOTHEKSAUSWEIS - {str(school_name)}")
# Student name - large and bold # Student name - large and bold
c.setFillColor(text_dark) c.setFillColor(text_dark)
@@ -4523,7 +4552,7 @@ def student_card_single_barcode_download(card_id):
# "SCHÜLERAUSWEIS" text in header # "SCHÜLERAUSWEIS" text in header
c.setFont("Helvetica-Bold", 11) c.setFont("Helvetica-Bold", 11)
c.setFillColor(white) c.setFillColor(white)
c.drawString(x_pos + 4*mm, y_pos - 6.5*mm, f"SCHÜLERAUSWEIS - {str(school_name)}") c.drawString(x_pos + 4*mm, y_pos - 6.5*mm, f"BIBLIOTHEKSAUSWEIS - {str(school_name)}")
# Student name - large and prominent # Student name - large and prominent
c.setFillColor(text_dark) c.setFillColor(text_dark)
@@ -5530,10 +5559,10 @@ def upload_item():
app.logger.info(f"Starting image upload session {upload_session_id} - Files: {len(images)}, User: {encrypt_text(username)}") app.logger.info(f"Starting image upload session {upload_session_id} - Files: {len(images)}, User: {encrypt_text(username)}")
for index, image in enumerate(images): for index, image in enumerate(images):
if upload_mode == 'library': #if upload_mode == 'library':
app.logger.info(f"[Upload {upload_session_id}] Skipping manual upload (Library Mode)") # app.logger.info(f"[Upload {upload_session_id}] Skipping manual upload (Library Mode)")
skipped_count += 1 # skipped_count += 1
continue # continue
if not image or not image.filename: if not image or not image.filename:
skipped_count += 1 skipped_count += 1
@@ -6212,174 +6241,6 @@ def bulk_delete_items():
if client: if client:
client.close() client.close()
@app.route('/edit_item/<id>', methods=['POST'])
def edit_item(id):
"""
Route for editing an existing inventory item.
Args:
id (str): ID of the item to edit
Returns:
flask.Response: Redirect to admin homepage with status message
"""
if 'username' not in session:
flash('Nicht angemeldet.', 'error')
return redirect(url_for('login'))
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['actions'].get('can_edit', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
return redirect(url_for('home_admin'))
if not cfg.MODULES.is_enabled('inventory'):
flash('Bibliotheks-Modul ist deaktiviert.', 'error')
return redirect(url_for('library_view'))
fs = get_gridfs()
name = sanitize_form_value(request.form.get('name'))
ort = sanitize_form_value(request.form.get('ort'))
beschreibung = sanitize_form_value(request.form.get('beschreibung'))
filter1 = sanitize_form_value(request.form.getlist('filter'))
filter2 = sanitize_form_value(request.form.getlist('filter2'))
filter3 = sanitize_form_value(request.form.getlist('filter3'))
# Expand special "all values" selections for predefined filters.
filter1 = expand_filter_selection(filter1, 1)
filter2 = expand_filter_selection(filter2, 2)
anschaffungs_jahr = sanitize_form_value(request.form.get('anschaffungsjahr'))
anschaffungs_kosten = sanitize_form_value(request.form.get('anschaffungskosten'))
code_4 = sanitize_form_value(request.form.get('code_4'))
isbn_raw = sanitize_form_value(request.form.get('isbn', ''))
reservierbar = 'reservierbar' in request.form
item_isbn = ''
item_type = 'general'
if cfg.MODULES.is_enabled('library'):
item_isbn = normalize_and_validate_isbn(isbn_raw)
if isbn_raw and not item_isbn:
flash('Ungültige ISBN. Bitte ISBN-10 oder ISBN-13 verwenden.', 'error')
return redirect(url_for('home_admin'))
if item_isbn:
item_type = 'book'
if code_4 and not it.is_code_unique(code_4, exclude_id=id):
flash('Der Code wird bereits verwendet. Bitte wählen Sie einen anderen Code.', 'error')
return redirect(url_for('home_admin'))
current_item = it.get_item(id)
if not current_item:
flash('Element nicht gefunden', 'error')
return redirect(url_for('home_admin'))
verfuegbar = current_item.get('Verfuegbar', True)
images_to_keep = request.form.getlist('existing_images')
original_images = current_item.get('Images', [])
images = [img for img in original_images if img in images_to_keep]
new_images = request.files.getlist('new_images')
for image in new_images:
if image and image.filename:
is_allowed, error_message = allowed_file(image.filename, image)
if is_allowed:
try:
secure_name = secure_filename(image.filename)
image.seek(0)
image_bytes = image.read()
if not image_bytes:
app.logger.error(f"Failed to read image in edit_item (0 bytes) for {secure_name}")
continue
optimized_io = io.BytesIO()
with Image.open(io.BytesIO(image_bytes)) as img:
if img.mode not in ('RGB', 'RGBA'):
img = img.convert('RGBA')
max_width = 500
if img.width > max_width:
ratio = max_width / img.width
new_size = (max_width, int(img.height * ratio))
img = img.resize(new_size, Image.Resampling.LANCZOS)
img.save(optimized_io, format='WEBP', quality=85, optimize=True)
optimized_io.seek(0)
new_filename = f"{uuid.uuid4().hex}_{int(time.time())}.webp"
fs.put(
optimized_io,
filename=new_filename,
content_type='image/webp',
metadata={
'original_filename': secure_name,
'upload_context': 'edit_item',
'item_id': id
}
)
images.append(new_filename)
except Exception as e:
app.logger.error(f"Error processing new image in edit_item: {str(e)}")
else:
flash(error_message, 'error')
return redirect(url_for('home_admin'))
predefined_locations = it.get_predefined_locations()
if ort and ort not in predefined_locations:
it.add_predefined_location(ort)
result = it.update_item(
id=id,
name=name,
ort=ort,
beschreibung=beschreibung,
images=images,
verfuegbar=verfuegbar,
filter1=filter1,
filter2=filter2,
filter3=filter3,
ansch_jahr=anschaffungs_jahr,
ansch_kost=anschaffungs_kosten,
code_4=code_4,
reservierbar=reservierbar,
isbn=item_isbn,
item_type=item_type
)
if result:
flash('Element erfolgreich aktualisiert (und ggf. Gruppe synchronisiert)', 'success')
else:
flash('Fehler beim Aktualisieren des Elements', 'error')
return redirect(url_for('home_admin'))
def is_library_item(item):
"""
Prüft, ob ein Artikel ein Bibliotheks-Item ist.
- 'other', None oder Leerstring -> Inventarsystem (False)
- Jeder andere Medientyp ('Buch', 'CD', etc.) -> Bibliothek (True)
"""
if not item:
return False
item_type = item.get('ItemType', 'other')
if not item_type:
return False
return item_type.strip().lower() != 'other'
@app.route('/item_edit/<id>', methods=['GET', 'POST']) @app.route('/item_edit/<id>', methods=['GET', 'POST'])
def item_edit(id): def item_edit(id):
if 'username' not in session: if 'username' not in session:
@@ -7861,35 +7722,282 @@ def register():
permission_page_options=PERMISSION_PAGE_OPTIONS permission_page_options=PERMISSION_PAGE_OPTIONS
) )
def parse_csv_users(file_bytes):
"""
Liest CSV-Dateien extrem robust ein:
- Erkennt automatisch UTF-8, UTF-8-SIG und Latin-1 (für Excel-Umlaute).
- Erkennt automatisch das Trennzeichen (; oder , oder Tab).
- Erkennt Vor- und Nachname unabhängig von der Spaltenreihenfolge und Headernamen.
"""
# 1. Dekodierung mit Fallback für deutsche Excel-Dateien
try:
content = file_bytes.decode('utf-8-sig')
except UnicodeDecodeError:
content = file_bytes.decode('latin-1')
lines = [line.strip() for line in content.splitlines() if line.strip()]
if not lines:
return []
# 2. Trennzeichen ermitteln (; , oder \t)
first_line = lines[0]
if first_line.count(';') >= first_line.count(','):
delimiter = ';'
elif first_line.count('\t') > first_line.count(','):
delimiter = '\t'
else:
delimiter = ','
stream = io.StringIO(content, newline=None)
reader = csv.DictReader(stream, delimiter=delimiter)
parsed_users = []
# 3. Auswertung mit DictReader (wenn Header vorhanden sind)
if reader.fieldnames:
for row in reader:
# Keys normalisieren (kleingeschrieben, ohne Leerzeichen)
cleaned_row = {str(k).strip().lower(): str(v).strip() for k, v in row.items() if k and v}
# Dynamisches Mapping für Vornamen
name = (
cleaned_row.get('vorname') or
cleaned_row.get('first_name') or
cleaned_row.get('firstname') or
cleaned_row.get('name') or ''
)
# Dynamisches Mapping für Nachnamen
last_name = (
cleaned_row.get('nachname') or
cleaned_row.get('last_name') or
cleaned_row.get('lastname') or
cleaned_row.get('surname') or
cleaned_row.get('familienname') or ''
)
# Falls Header nicht erkannt wurden, aber Werte vorhanden sind (z.B. CSV ohne Header)
if not name and not last_name:
vals = [str(v).strip() for v in row.values() if v]
if len(vals) >= 2:
name, last_name = vals[0], vals[1]
if name or last_name:
parsed_users.append({'name': name, 'last_name': last_name})
else:
# Fallback für dateien ohne Header
stream.seek(0)
raw_reader = csv.reader(stream, delimiter=delimiter)
for row in raw_reader:
clean_row = [str(cell).strip() for cell in row if str(cell).strip()]
if len(clean_row) >= 2:
# Header-Zeilen überspringen
if clean_row[0].lower() in ['vorname', 'first_name', 'name'] and clean_row[1].lower() in ['nachname', 'last_name']:
continue
parsed_users.append({'name': clean_row[0], 'last_name': clean_row[1]})
return parsed_users
def generate_compliant_password(length=16):
lowers = string.ascii_lowercase
uppers = string.ascii_uppercase
digits = string.digits
symbols = "!@#$%^&*()_+~|}{[]:;?><,.-="
# Ensure at least one character from each required category
pwd = [
secrets.choice(lowers),
secrets.choice(uppers),
secrets.choice(digits),
secrets.choice(symbols)
]
all_chars = lowers + uppers + digits + symbols
pwd += [secrets.choice(all_chars) for _ in range(length - 4)]
# Shuffle so guaranteed types aren't always at the start
secrets.SystemRandom().shuffle(pwd)
return "".join(pwd)
def generate_credentials_pdf(created_users):
"""
Creates a PDF in memory with 2 user credential cards per A4 page.
created_users: list of dicts [{'name': ..., 'last_name': ..., 'username': ..., 'password': ...}]
"""
buffer = io.BytesIO()
pdf = canvas.Canvas(buffer, pagesize=A4)
width, height = A4 # 595.27 x 841.89 points
card_height = height / 2.0 # Split page into 2 equal halves
for i, user in enumerate(created_users):
page_slot = i % 2 # 0 = Top half, 1 = Bottom half
# If starting a new page (except for the very first item)
if i > 0 and page_slot == 0:
pdf.showPage()
# Calculate Y offset for card position
y_offset = height - (page_slot + 1) * card_height
# Card Container Box
margin = 35
box_x = margin
box_y = y_offset + margin
box_w = width - (2 * margin)
box_h = card_height - (2 * margin)
# Outer Border
pdf.setStrokeColor(colors.HexColor('#CBD5E1'))
pdf.setLineWidth(1)
pdf.rect(box_x, box_y, box_w, box_h, fill=0)
# Header Banner inside Card
pdf.setFillColor(colors.HexColor('#1E293B'))
pdf.rect(box_x, box_y + box_h - 45, box_w, 45, fill=1, stroke=0)
# Header Title Text
pdf.setFillColor(colors.white)
pdf.setFont("Helvetica-Bold", 14)
pdf.drawString(box_x + 20, box_y + box_h - 28, "Zugangsdaten / Account Credentials")
# User Info Details
content_y = box_y + box_h - 80
# Name
pdf.setFillColor(colors.HexColor('#0F172A'))
pdf.setFont("Helvetica-Bold", 12)
pdf.drawString(box_x + 25, content_y, f"Name: {user['name']} {user['last_name']}")
# Username
content_y -= 35
pdf.setFont("Helvetica", 11)
pdf.setFillColor(colors.HexColor('#475569'))
pdf.drawString(box_x + 25, content_y, "Benutzername:")
pdf.setFont("Helvetica-Bold", 13)
pdf.setFillColor(colors.HexColor('#0F172A'))
pdf.drawString(box_x + 160, content_y, user['username'])
# Password
content_y -= 30
pdf.setFont("Helvetica", 11)
pdf.setFillColor(colors.HexColor('#475569'))
pdf.drawString(box_x + 25, content_y, "Passwort:")
pdf.setFont("Courier-Bold", 13)
pdf.setFillColor(colors.HexColor('#0F172A'))
pdf.drawString(box_x + 160, content_y, user['password'])
# Security Footer Note
content_y -= 45
pdf.setFont("Helvetica-Oblique", 9)
pdf.setFillColor(colors.HexColor('#64748B'))
pdf.drawString(box_x + 25, content_y, "Hinweis: Bitte ändern Sie Ihr Passwort nach der ersten Anmeldung.")
# Dashed Cut Line between top and bottom cards
if page_slot == 0 and i < len(created_users) - 1:
pdf.setDash(4, 4)
pdf.setStrokeColor(colors.HexColor('#94A3B8'))
pdf.line(0, card_height, width, card_height)
pdf.setDash() # Reset dash
pdf.save()
buffer.seek(0)
return buffer
@app.route('/register/csv', methods=['POST'])
def register_csv():
if 'username' not in session:
flash('Ihnen ist es nicht gestattet, diese Aktion auszuführen.', 'error')
return redirect(url_for('login'))
file = request.files.get('csv_file')
if not file or not file.filename.endswith('.csv'):
flash('Bitte laden Sie eine gültige CSV-Datei hoch.', 'error')
return redirect(url_for('register'))
permission_preset = (request.form.get('permission_preset') or 'standard_user').strip()
# 1. CSV über die robuste Funktion einlesen
file_bytes = file.stream.read()
raw_users = parse_csv_users(file_bytes)
if not raw_users:
flash('Keine gültigen Benutzer in der CSV-Datei gefunden. Bitte prüfen Sie das Format.', 'error')
return redirect(url_for('register'))
created_users = []
for entry in raw_users:
name = entry['name']
last_name = entry['last_name']
if not name or not last_name:
continue
# Benutzernamen & Passwort generieren
username = us.build_unique_username_from_name(name, last_name)
password = generate_compliant_password(16)
# In DB speichern
success = us.add_user(
username=username,
password=password,
name=name,
last_name=last_name,
is_student=False,
student_card_id=None,
max_borrow_days=None,
permission_preset=permission_preset,
)
if success:
created_users.append({
'name': name,
'last_name': last_name,
'username': username,
'password': password
})
if not created_users:
flash('Fehler beim Erstellen der Benutzer aus der CSV.', 'error')
return redirect(url_for('register'))
# PDF mit Zugangsdaten generieren
pdf_buffer = generate_credentials_pdf(created_users)
return send_file(
pdf_buffer,
as_attachment=True,
download_name='benutzer_zugangsdaten.pdf',
mimetype='application/pdf'
)
@app.route('/user_del', methods=['GET']) @app.route('/user_del', methods=['GET'])
def user_del(): def user_del():
""" """
User deletion interface. User deletion interface.
Displays a list of users that can be deleted by an administrator. Displays a list of users that can be deleted by an administrator.
Prevents self-deletion by hiding the current user from the list. Prevents self-deletion by hiding the current user from the list.
Returns:
flask.Response: Rendered template with user list or redirect
""" """
if 'username' not in session: if 'username' not in session:
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') flash(
'Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adresse zu nutzen. Bitte melden Sie sich an!',
'error')
return redirect(url_for('login')) return redirect(url_for('login'))
# Abruf aller User (falls get_all_users tenant_id unterstützt, kann diese hier übergeben werden)
all_users = us.get_all_users() all_users = us.get_all_users()
users_list = [] users_list = []
for user in all_users: for user in all_users:
username = None username = user.get('Username')
for field in ['Username']:
if field in user: if username and username != session.get('username'):
username = user[field]
break
if username and username != session['username']:
try: try:
permissions_payload = us.get_effective_permissions(username) permissions_payload = us.get_effective_permissions(username)
except Exception: except Exception:
permissions_payload = us.build_default_permission_payload('standard_user') permissions_payload = us.build_default_permission_payload('standard_user')
try: try:
name = us.get_name(username) name = us.get_name(username)
last_name = us.get_last_name(username) last_name = us.get_last_name(username)
@@ -7901,10 +8009,11 @@ def user_del():
fullname = last_name fullname = last_name
else: else:
fullname = None fullname = None
except: except Exception:
name = "" name = ""
last_name = "" last_name = ""
fullname = None fullname = None
users_list.append({ users_list.append({
'username': decrypt_text(username), 'username': decrypt_text(username),
'admin': user.get('Admin', False), 'admin': user.get('Admin', False),
@@ -7915,7 +8024,7 @@ def user_del():
'action_permissions': permissions_payload.get('actions', {}), 'action_permissions': permissions_payload.get('actions', {}),
'page_permissions': permissions_payload.get('pages', {}), 'page_permissions': permissions_payload.get('pages', {}),
}) })
return render_template( return render_template(
'user_del.html', 'user_del.html',
users=users_list, users=users_list,
@@ -7928,68 +8037,72 @@ def user_del():
def delete_user(): def delete_user():
""" """
Process user deletion request. Process user deletion request.
Deletes a specified user from the system. Deletes a specified user from the system directly via the tenant db.
Includes safety checks to prevent self-deletion. Includes safety checks to prevent self-deletion.
Returns:
flask.Response: Redirect to the user deletion interface with status
""" """
if 'username' not in session: if 'username' not in session:
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') flash('Ihnen ist es nicht gestattet, diese Aktion auszuführen. Bitte melden Sie sich an!', 'error')
return redirect(url_for('login')) return redirect(url_for('login'))
username = request.form.get('username') username = request.form.get('username')
if not username: if not username:
flash('Kein Benutzer ausgewählt', 'error') flash('Kein Benutzer ausgewählt', 'error')
return redirect(url_for('user_del')) return redirect(url_for('user_del'))
# Prevent self-deletion if username == session.get('username'):
if username == session['username']:
flash('Sie können Ihr eigenes Konto nicht löschen', 'error') flash('Sie können Ihr eigenes Konto nicht löschen', 'error')
return redirect(url_for('user_del')) return redirect(url_for('user_del'))
# Reset this user's borrowings and free items before deleting the user
try: try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[MONGODB_DB] tenant_id = session.get('tenant_id')
db = us._get_tenant_db(client, tenant_id)
ausleihungen = db['ausleihungen'] ausleihungen = db['ausleihungen']
items_col = db['items'] items_col = db['items']
users_col = db['users'] # Direkter Zugriff auf die User-Collection
now = datetime.datetime.now() now = datetime.datetime.now()
# Complete all active borrowings of this user # 1. Aktive Ausleihen abschließen
ausleihungen.update_many( ausleihungen.update_many(
{'User': username, 'Status': 'active'}, {'User': username, 'Status': 'active'},
{'$set': {'Status': 'completed', 'End': now, 'LastUpdated': now}} {'$set': {'Status': 'completed', 'End': now, 'LastUpdated': now}}
) )
# Cancel all planned borrowings of this user # 2. Geplante Ausleihen stornieren
ausleihungen.update_many( ausleihungen.update_many(
{'User': username, 'Status': 'planned'}, {'User': username, 'Status': 'planned'},
{'$set': {'Status': 'cancelled', 'LastUpdated': now}} {'$set': {'Status': 'cancelled', 'LastUpdated': now}}
) )
# Free all items currently associated with this user # 3. Inventar wieder verfügbar machen
items_col.update_many( items_col.update_many(
{'User': username}, {'User': username},
{'$set': {'Verfuegbar': True, 'LastUpdated': now}, '$unset': {'User': ""}} {'$set': {'Verfuegbar': True, 'LastUpdated': now}, '$unset': {'User': ""}}
) )
client.close() # 4. Den Benutzer direkt in der überprüften DB-Verbindung löschen
except Exception as e: # Achte auf die exakte Großschreibung 'Username' (so wie in add_user definiert)
app.logger.error(f"Error resetting borrowings for user {encrypt_text(username)}: {e}") delete_result = users_col.delete_one({'Username': username})
flash(f'Warnung: Ausleihungen/Reservierungen für {username} konnten nicht vollständig zurückgesetzt werden', 'warning')
# 5. Explizite Erfolgskontrolle
if delete_result.deleted_count > 0:
flash(f'Benutzer {username} erfolgreich gelöscht', 'success')
else:
# Fallback, falls der Nutzer nicht gefunden wurde (Fehlervermeidung)
flash(f'Löschen fehlgeschlagen: Benutzer {username} wurde in der Datenbank nicht gefunden.', 'error')
# Delete the user
try:
us.delete_user(username)
flash(f'Benutzer {username} erfolgreich gelöscht', 'success')
except Exception as e: except Exception as e:
app.logger.error(f"Error deleting user {encrypt_text(username)}: {e}") app.logger.error(f"Error resetting borrowings or deleting user {username}: {e}")
flash('Fehler beim Löschen des Benutzers', 'error') flash('Kritischer Fehler beim Löschen des Benutzers', 'error')
finally:
# Garantiert, dass die DB-Verbindung geschlossen wird
if 'client' in locals():
client.close()
return redirect(url_for('user_del')) return redirect(url_for('user_del'))
@app.route('/admin/borrowings') @app.route('/admin/borrowings')
def admin_borrowings(): def admin_borrowings():
""" """
@@ -9955,8 +10068,8 @@ def download_book_cover():
return jsonify({"error": "Only public HTTPS URLs are allowed"}), 400 return jsonify({"error": "Only public HTTPS URLs are allowed"}), 400
# 2. SSRF Protection: Strict Allowlist Check # 2. SSRF Protection: Strict Allowlist Check
if parsed_url.netloc not in ALLOWED_COVER_DOMAINS: # if parsed_url.netloc not in ALLOWED_COVER_DOMAINS:
return jsonify({"error": "Target host is not an allowed book cover provider"}), 403 # return jsonify({"error": "Target host is not an allowed book cover provider"}), 403
# Download the image (allow_redirects=False prevents redirecting to internal IPs) # Download the image (allow_redirects=False prevents redirecting to internal IPs)
response = requests.get(image_url, stream=True, timeout=10, allow_redirects=False) response = requests.get(image_url, stream=True, timeout=10, allow_redirects=False)
+26 -27
View File
@@ -211,6 +211,7 @@ PERMISSION_PRESETS = {
}, },
'pages': { 'pages': {
'home': True, 'home': True,
'home_admin': True,
'tutorial_page': True, 'tutorial_page': True,
'my_borrowed_items': True, 'my_borrowed_items': True,
'notifications_view': True, 'notifications_view': True,
@@ -369,10 +370,10 @@ def update_user_permissions(username, preset_key, action_permissions=None, page_
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client) db = _get_tenant_db(client)
users = db['users'] users = db['users']
result = users.update_one({'Username': dp.encrypt_text(username)}, {'$set': update_data}) result = users.update_one({'Username': username}, {'$set': update_data})
if result.matched_count == 0: if result.matched_count == 0:
result = users.update_one({'username': dp.encrypt_text(username)}, {'$set': update_data}) result = users.update_one({'username': username}, {'$set': update_data})
client.close() client.close()
return result.matched_count > 0 return result.matched_count > 0
@@ -384,7 +385,7 @@ def get_favorites(username):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client) db = _get_tenant_db(client)
users = db['users'] users = db['users']
user = users.find_one({'Username': dp.encrypt_text(username)}) or users.find_one({'username': dp.encrypt_text(username)}) user = users.find_one({'Username': username}) or users.find_one({'username': username})
client.close() client.close()
if not user: if not user:
return [] return []
@@ -399,7 +400,7 @@ def add_favorite(username, item_id):
db = _get_tenant_db(client) db = _get_tenant_db(client)
users = db['users'] users = db['users']
users.update_one( users.update_one(
{'$or': [{'Username': dp.encrypt_text(username)}, {'username': dp.encrypt_text(username)}]}, {'$or': [{'Username': username}, {'username': username}]},
{'$addToSet': {'favorites': ObjectId(item_id)}} {'$addToSet': {'favorites': ObjectId(item_id)}}
) )
client.close() client.close()
@@ -414,7 +415,7 @@ def remove_favorite(username, item_id):
db = _get_tenant_db(client) db = _get_tenant_db(client)
users = db['users'] users = db['users']
users.update_one( users.update_one(
{'$or': [{'Username': dp.encrypt_text(username)}, {'username': dp.encrypt_text(username)}]}, {'$or': [{'Username': username}, {'username': username}]},
{'$pull': {'favorites': ObjectId(item_id)}} {'$pull': {'favorites': ObjectId(item_id)}}
) )
client.close() client.close()
@@ -496,7 +497,7 @@ def check_nm_pwd(username, password):
db = client[db_name] db = client[db_name]
users = db['users'] users = db['users']
query = {'$or': [{'Username': dp.encrypt_text(username)}, {'username': dp.encrypt_text(username)}]} query = {'$or': [{'Username': username}, {'username': username}]}
user_record = users.find_one(query) user_record = users.find_one(query)
if user_record is None: if user_record is None:
@@ -507,8 +508,6 @@ def check_nm_pwd(username, password):
default_users = client[cfg.MONGODB_DB]['users'] default_users = client[cfg.MONGODB_DB]['users']
user_record_fallback = default_users.find_one( user_record_fallback = default_users.find_one(
{'$or': [ {'$or': [
{'Username': dp.encrypt_text(username)},
{'username': dp.encrypt_text(username)},
{'Username': username}, {'Username': username},
{'username': username}, {'username': username},
]} ]}
@@ -711,9 +710,9 @@ def make_admin(username):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client) db = _get_tenant_db(client)
users = db['users'] users = db['users']
result = users.update_one({'Username': dp.encrypt_text(username)}, {'$set': {'Admin': True}}) result = users.update_one({'Username': username}, {'$set': {'Admin': True}})
if result.matched_count == 0: if result.matched_count == 0:
result = users.update_one({'username': dp.encrypt_text(username)}, {'$set': {'Admin': True}}) result = users.update_one({'username': username}, {'$set': {'Admin': True}})
client.close() client.close()
return result.matched_count > 0 return result.matched_count > 0
@@ -723,9 +722,9 @@ def remove_admin(username):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client) db = _get_tenant_db(client)
users = db['users'] users = db['users']
result = users.update_one({'Username': dp.encrypt_text(username)}, {'$set': {'Admin': False}}) result = users.update_one({'Username': username}, {'$set': {'Admin': False}})
if result.matched_count == 0: if result.matched_count == 0:
result = users.update_one({'username': dp.encrypt_text(username)}, {'$set': {'Admin': False}}) result = users.update_one({'username': username}, {'$set': {'Admin': False}})
client.close() client.close()
return result.matched_count > 0 return result.matched_count > 0
@@ -737,7 +736,7 @@ def get_user(username):
def find_in_db(database_name): def find_in_db(database_name):
db = client[database_name] db = client[database_name]
users = db['users'] users = db['users']
return users.find_one({'Username': dp.encrypt_text(username)}) or users.find_one({'username': dp.encrypt_text(username)}) or users.find_one({'username': username}) or users.find_one({'Username': username}) return users.find_one({'Username': username}) or users.find_one({'username': username}) or users.find_one({'username': username}) or users.find_one({'Username': username})
tenant_db, tenant_id = _resolve_request_tenant_db() tenant_db, tenant_id = _resolve_request_tenant_db()
if tenant_db: if tenant_db:
@@ -773,12 +772,12 @@ def update_active_ausleihung(username, id_item, ausleihung):
users = db['users'] users = db['users']
result = users.update_one( result = users.update_one(
{'Username': dp.encrypt_text(username)}, {'Username': username},
{'$set': {'active_ausleihung': {'Item': id_item, 'Ausleihung': ausleihung}}} {'$set': {'active_ausleihung': {'Item': id_item, 'Ausleihung': ausleihung}}}
) )
if result.matched_count == 0: if result.matched_count == 0:
users.update_one( users.update_one(
{'username': dp.encrypt_text(username)}, {'username': username},
{'$set': {'active_ausleihung': {'Item': id_item, 'Ausleihung': ausleihung}}} {'$set': {'active_ausleihung': {'Item': id_item, 'Ausleihung': ausleihung}}}
) )
client.close() client.close()
@@ -791,7 +790,7 @@ def get_active_ausleihung(username):
db = _get_tenant_db(client) db = _get_tenant_db(client)
users = db['users'] users = db['users']
user = users.find_one({'Username': dp.encrypt_text(username)}) or users.find_one({'username': dp.encrypt_text(username)}) user = users.find_one({'Username': username}) or users.find_one({'username': username})
client.close() client.close()
if not user: if not user:
@@ -806,7 +805,7 @@ def has_active_borrowing(username):
db = _get_tenant_db(client) db = _get_tenant_db(client)
users = db['users'] users = db['users']
user = users.find_one({'username': dp.encrypt_text(username)}) or users.find_one({'Username': dp.encrypt_text(username)}) user = users.find_one({'username': username}) or users.find_one({'Username': username})
client.close() client.close()
if not user: if not user:
@@ -823,9 +822,9 @@ def delete_user(username):
db = _get_tenant_db(client) db = _get_tenant_db(client)
users = db['users'] users = db['users']
result = users.delete_one({'username': dp.encrypt_text(username)}) result = users.delete_one({'username': username})
if result.deleted_count == 0: if result.deleted_count == 0:
result = users.delete_one({'Username': dp.encrypt_text(username)}) result = users.delete_one({'Username': username})
client.close() client.close()
return result.deleted_count > 0 return result.deleted_count > 0
@@ -840,9 +839,9 @@ def update_active_borrowing(username, item_id, status):
update_data = {'$set': {'active_borrowing': status, 'borrowed_item': item_id if status else None}} update_data = {'$set': {'active_borrowing': status, 'borrowed_item': item_id if status else None}}
result = users.update_one({'username': dp.encrypt_text(username)}, update_data) result = users.update_one({'username': username}, update_data)
if result.matched_count == 0: if result.matched_count == 0:
result = users.update_one({'Username': dp.encrypt_text(username)}, update_data) result = users.update_one({'Username': username}, update_data)
client.close() client.close()
return result.modified_count > 0 return result.modified_count > 0
@@ -856,7 +855,7 @@ def get_name(username):
db = _get_tenant_db(client) db = _get_tenant_db(client)
users = db['users'] users = db['users']
user = users.find_one({'Username': dp.encrypt_text(username)}) or users.find_one({'username': dp.encrypt_text(username)}) user = users.find_one({'Username': username}) or users.find_one({'username': username})
client.close() client.close()
if not user or not user.get("name"): if not user or not user.get("name"):
@@ -871,7 +870,7 @@ def get_last_name(username):
db = _get_tenant_db(client) db = _get_tenant_db(client)
users = db['users'] users = db['users']
user = users.find_one({'Username': dp.encrypt_text(username)}) or users.find_one({'username': dp.encrypt_text(username)}) user = users.find_one({'Username': username}) or users.find_one({'username': username})
client.close() client.close()
if not user or not user.get("last_name"): if not user or not user.get("last_name"):
@@ -906,12 +905,12 @@ def update_password(username, new_password):
hashed_password = hashing(new_password) hashed_password = hashing(new_password)
result = users.update_one( result = users.update_one(
{'Username': dp.encrypt_text(username)}, {'Username': username},
{'$set': {'Password': hashed_password}} {'$set': {'Password': hashed_password}}
) )
if result.matched_count == 0: if result.matched_count == 0:
result = users.update_one( result = users.update_one(
{'username': dp.encrypt_text(username)}, {'username': username},
{'$set': {'Password': hashed_password}} {'$set': {'Password': hashed_password}}
) )
@@ -933,12 +932,12 @@ def update_user_name(username, name, last_name):
safe_last_name = dp.encrypt_text(last_name.strip()) if last_name else '' safe_last_name = dp.encrypt_text(last_name.strip()) if last_name else ''
result = users.update_one( result = users.update_one(
{'Username': dp.encrypt_text(username)}, {'Username': username},
{'$set': {'name': safe_name, 'last_name': safe_last_name}} {'$set': {'name': safe_name, 'last_name': safe_last_name}}
) )
if result.matched_count == 0: if result.matched_count == 0:
result = users.update_one( result = users.update_one(
{'username': dp.encrypt_text(username)}, {'username': username},
{'$set': {'name': safe_name, 'last_name': safe_last_name}} {'$set': {'name': safe_name, 'last_name': safe_last_name}}
) )
File diff suppressed because it is too large Load Diff
-752
View File
@@ -1,752 +0,0 @@
{% extends "base.html" %}
{% block title %}Bibliothek - {{ APP_VERSION }}{% endblock %}
{% block content %}
<div class="content-container">
<!-- Header Section -->
<div class="header-section">
<div class="header-title-group">
<h1>📚 Bibliothek</h1>
<p class="subtitle">Bücher, CDs und weitere Medien</p>
</div>
</div>
<!-- Search & Filter Section -->
<div class="search-filter-section">
<input type="text" id="searchInput" placeholder="Nach Titel, Autor, ISBN suchen..." class="search-input">
<div class="filter-group" id="filterGroup">
<button class="filter-btn" id="filterBtn" aria-expanded="false" aria-label="Filter öffnen">
🔍 Filter <span class="filter-arrow"></span>
</button>
<div class="filter-dropdown" id="filterDropdown" style="display: none;">
<div class="filter-section">
<h4>Medientyp</h4>
<div class="filter-options">
<label><input type="checkbox" class="filter-checkbox" data-filter="type" value="book"> Buch</label>
<label><input type="checkbox" class="filter-checkbox" data-filter="type" value="cd"> CD/DVD</label>
<label><input type="checkbox" class="filter-checkbox" data-filter="type" value="other"> Sonstige</label>
</div>
</div>
<div class="filter-section">
<h4>Status</h4>
<div class="filter-options">
<label><input type="checkbox" class="filter-checkbox" data-filter="status" value="available"> Verfügbar</label>
<label><input type="checkbox" class="filter-checkbox" data-filter="status" value="borrowed"> Ausgeliehen</label>
<label><input type="checkbox" class="filter-checkbox" data-filter="status" value="reserved"> Reserviert</label>
</div>
</div>
<button id="clearFiltersBtn" class="button">Filter zurücksetzen</button>
</div>
</div>
<!-- Barcode Scanner -->
<button id="scannerBtn" class="scan-button" aria-expanded="false" aria-label="Scanner öffnen">
📱 Scanner <span class="scanner-arrow"></span>
</button>
</div>
<!-- Scanner Panel -->
<div id="qrContainer" class="qr-container" style="display: none;">
<div id="qr-reader" style="width: auto; height: 300px;"></div>
<span id="qr-result" style="display: none;"></span>
</div>
<!-- Table Header (only in table mode) -->
<div class="library-table-wrapper">
<div class="table-header" id="tableHeader" style="display: none;">
<div class="table-row">
<div class="table-cell title-cell">Titel</div>
<div class="table-cell author-cell">Autor/Künstler</div>
<div class="table-cell isbn-cell">ISBN/Code</div>
<div class="table-cell type-cell">Typ</div>
<div class="table-cell status-cell">Status</div>
<div class="table-cell actions-cell">Aktionen</div>
</div>
</div>
<!-- Items Container -->
<div id="itemsContainer" class="items-container">
{% if library_items %}
{% for item in library_items %}
<div class="item-card library-item" data-item-id="{{ item._id }}" data-type="{{ item.get('ItemType', 'book') }}" data-status="{{ 'borrowed' if item.get('Verfuegbar') == False else 'available' }}">
<!-- Card Mode View -->
<div class="item-content card-mode">
<div class="item-image-wrapper">
{% if item.get('Image') %}
<img src="/uploads/{{ item.Image }}" alt="{{ item.Name }}" class="item-image">
{% else %}
<div class="placeholder-image">📚</div>
{% endif %}
</div>
<div class="item-details">
<h3 class="item-name">{{ item.Name }}</h3>
{% if item.get('Author') %}
<p class="item-author">Von: {{ item.Author }}</p>
{% endif %}
{% if item.get('ISBN') %}
<p class="item-isbn">ISBN: {{ item.ISBN }}</p>
{% endif %}
<div class="item-meta">
<span class="item-type">{{ item.get('ItemType', 'Medium') }}</span>
<span class="item-status {% if item.get('Verfuegbar') == False %}borrowed{% else %}available{% endif %}">
{% if item.get('Verfuegbar') == False %}Ausgeliehen{% else %}Verfügbar{% endif %}
</span>
</div>
</div>
</div>
<!-- Table Mode View -->
<div class="item-content table-mode" style="display: none;">
<div class="table-row">
<div class="table-cell title-cell">
<span class="descriptor">Titel:</span>
<span class="value">{{ item.Name }}</span>
</div>
<div class="table-cell author-cell">
<span class="descriptor">Autor:</span>
<span class="value">{{ item.get('Author', '—') }}</span>
</div>
<div class="table-cell isbn-cell">
<span class="descriptor">ISBN:</span>
<span class="value">{{ item.get('ISBN', '—') }}</span>
</div>
<div class="table-cell type-cell">
<span class="descriptor">Typ:</span>
<span class="value">{{ item.get('ItemType', 'Medium') }}</span>
</div>
<div class="table-cell status-cell">
<span class="descriptor">Status:</span>
<span class="value {% if item.get('Verfuegbar') == False %}borrowed{% else %}available{% endif %}">
{% if item.get('Verfuegbar') == False %}Ausgeliehen{% else %}Verfügbar{% endif %}
</span>
</div>
<div class="table-cell actions-cell">
<button class="action-button details-btn" data-item-id="{{ item._id }}">Details</button>
</div>
</div>
</div>
<!-- Action Buttons (Card Mode) -->
<div class="item-actions card-mode" style="display: flex;">
<button class="action-button details-btn" data-item-id="{{ item._id }}">Details</button>
{% if item.get('Verfuegbar') == True %}
<button class="action-button borrow-btn" data-item-id="{{ item._id }}">Ausleihen</button>
{% else %}
<button class="action-button return-btn" data-item-id="{{ item._id }}" disabled>Ausgeliehen</button>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<p>Keine Bibliotheks-Medien gefunden</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Detail Modal -->
<div id="detailModal" class="modal" style="display: none;">
<div class="modal-content">
<button class="modal-close" aria-label="Schließen"></button>
<div class="modal-body" id="modalBody">
<!-- Filled by JavaScript -->
</div>
</div>
</div>
<!-- Borrow Modal -->
<div id="borrowModal" class="modal" style="display: none;">
<div class="modal-content">
<h2>Ausleihen</h2>
<form id="borrowForm">
<input type="hidden" id="borrowItemId" name="item_id">
<div class="form-group">
<label for="studentIdInput">Schülerausweis-ID (optional):</label>
<input type="text" id="studentIdInput" name="student_id" placeholder="Ausweis scannen oder eingeben" class="form-input">
</div>
<div class="form-group">
<label for="borrowDaysInput">Ausleih-Dauer (Tage):</label>
<input type="number" id="borrowDaysInput" name="borrow_days" min="1" max="365" class="form-input" value="14">
</div>
<div class="form-actions">
<button type="submit" class="button primary-button">Ausleihen</button>
<button type="button" class="button cancel-button" onclick="document.getElementById('borrowModal').style.display='none'">Abbrechen</button>
</div>
</form>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
<script>
// View mode persistence
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
const MOBILE_LIBRARY_MAX_WIDTH = 900;
const MOBILE_LIBRARY_MIN_ITEMS = 24;
function isMobileViewport() {
return window.matchMedia(`(max-width: ${MOBILE_LIBRARY_MAX_WIDTH}px)`).matches;
}
function setViewMode(mode) {
localStorage.setItem(LIBRARY_VIEW_MODE_KEY, mode);
const container = document.getElementById('itemsContainer');
const tableHeader = document.getElementById('tableHeader');
const renderedItems = Array.from(container.querySelectorAll('.item-card'));
const virtualizationState = window.mobileLibraryVirtualizationState || null;
const detachedItems = virtualizationState && virtualizationState.active && Array.isArray(virtualizationState.items)
? virtualizationState.items.filter(item => !container.contains(item))
: [];
const items = [...renderedItems, ...detachedItems.filter(item => !container.contains(item))];
items.forEach(item => {
const cardContent = item.querySelector('.item-content.card-mode');
const cardActions = item.querySelector('.item-actions.card-mode');
const tableContent = item.querySelector('.item-content.table-mode');
if (mode === 'table') {
if (cardContent) cardContent.style.display = 'none';
if (cardActions) cardActions.style.display = 'none';
if (tableContent) tableContent.style.display = 'block';
} else {
if (cardContent) cardContent.style.display = 'flex';
if (cardActions) cardActions.style.display = 'flex';
if (tableContent) tableContent.style.display = 'none';
}
});
tableHeader.style.display = mode === 'table' ? 'block' : 'none';
document.getElementById('viewModeToggle').classList.toggle('open', mode === 'table');
}
function initMobileWindowedLibraryLoading() {
const container = document.getElementById('itemsContainer');
if (!container) {
return;
}
if (!window.mobileLibraryVirtualizationState) {
window.mobileLibraryVirtualizationState = {
active: false,
items: [],
topSpacer: null,
bottomSpacer: null,
averageItemHeight: Math.max(Math.round(window.innerHeight * 0.35), 230),
lastStart: -1,
lastEnd: -1,
framePending: false,
scrollListener: null,
resizeListener: null,
initialized: false
};
}
const state = window.mobileLibraryVirtualizationState;
function restoreAllImages() {
state.items.forEach(item => {
const image = item.querySelector('img.item-image');
if (!image) {
return;
}
const originalSrc = image.dataset.mobileSrc || '';
if (!image.getAttribute('src') && originalSrc) {
image.setAttribute('src', originalSrc);
}
});
}
function updateImageMemory(startIndex, endIndex) {
const imageBuffer = 4;
state.items.forEach((item, index) => {
const image = item.querySelector('img.item-image');
if (!image) {
return;
}
if (!image.dataset.mobileSrc) {
image.dataset.mobileSrc = image.getAttribute('src') || '';
}
image.loading = 'lazy';
image.decoding = 'async';
const shouldKeepLoaded = index >= startIndex - imageBuffer && index < endIndex + imageBuffer;
if (shouldKeepLoaded) {
if (!image.getAttribute('src') && image.dataset.mobileSrc) {
image.setAttribute('src', image.dataset.mobileSrc);
}
return;
}
if (image.getAttribute('src')) {
image.removeAttribute('src');
}
});
}
function renderVisibleWindow() {
if (!state.active || !state.topSpacer || !state.bottomSpacer) {
return;
}
const viewportHeight = window.innerHeight || 800;
const scrollTop = window.scrollY || window.pageYOffset || 0;
const renderBuffer = viewportHeight * 1.5;
let startIndex = Math.max(0, Math.floor((scrollTop - renderBuffer) / state.averageItemHeight));
let endIndex = Math.min(state.items.length, Math.ceil((scrollTop + viewportHeight + renderBuffer) / state.averageItemHeight));
if (endIndex - startIndex < 14) {
endIndex = Math.min(state.items.length, startIndex + 14);
}
if (startIndex === state.lastStart && endIndex === state.lastEnd) {
return;
}
state.lastStart = startIndex;
state.lastEnd = endIndex;
while (state.topSpacer.nextSibling && state.topSpacer.nextSibling !== state.bottomSpacer) {
state.topSpacer.nextSibling.remove();
}
const fragment = document.createDocumentFragment();
for (let index = startIndex; index < endIndex; index += 1) {
fragment.appendChild(state.items[index]);
}
container.insertBefore(fragment, state.bottomSpacer);
let measuredHeightSum = 0;
let measuredCount = 0;
for (let index = startIndex; index < endIndex; index += 1) {
const height = state.items[index].getBoundingClientRect().height;
if (height > 0) {
measuredHeightSum += height;
measuredCount += 1;
}
}
if (measuredCount > 0) {
const measuredAverage = measuredHeightSum / measuredCount;
state.averageItemHeight = Math.round((state.averageItemHeight * 3 + measuredAverage) / 4);
}
state.topSpacer.style.height = `${Math.max(0, startIndex * state.averageItemHeight)}px`;
state.bottomSpacer.style.height = `${Math.max(0, (state.items.length - endIndex) * state.averageItemHeight)}px`;
updateImageMemory(startIndex, endIndex);
}
function scheduleWindowRender() {
if (!state.active || state.framePending) {
return;
}
state.framePending = true;
requestAnimationFrame(() => {
state.framePending = false;
renderVisibleWindow();
});
}
function activateVirtualization() {
if (state.active) {
return;
}
if (!Array.isArray(state.items) || state.items.length === 0) {
state.items = Array.from(container.querySelectorAll('.library-item'));
}
if (state.items.length <= MOBILE_LIBRARY_MIN_ITEMS) {
return;
}
state.topSpacer = document.createElement('div');
state.topSpacer.className = 'mobile-window-spacer top';
state.bottomSpacer = document.createElement('div');
state.bottomSpacer.className = 'mobile-window-spacer bottom';
state.items.forEach(item => item.remove());
container.appendChild(state.topSpacer);
container.appendChild(state.bottomSpacer);
state.lastStart = -1;
state.lastEnd = -1;
state.framePending = false;
state.active = true;
if (!state.scrollListener) {
state.scrollListener = () => scheduleWindowRender();
window.addEventListener('scroll', state.scrollListener, { passive: true });
}
scheduleWindowRender();
}
function deactivateVirtualization() {
if (!state.active) {
if (!Array.isArray(state.items) || state.items.length === 0) {
state.items = Array.from(container.querySelectorAll('.library-item'));
}
return;
}
if (state.scrollListener) {
window.removeEventListener('scroll', state.scrollListener);
state.scrollListener = null;
}
if (state.topSpacer && state.topSpacer.parentNode === container) {
state.topSpacer.remove();
}
if (state.bottomSpacer && state.bottomSpacer.parentNode === container) {
state.bottomSpacer.remove();
}
const fragment = document.createDocumentFragment();
state.items.forEach(item => fragment.appendChild(item));
container.appendChild(fragment);
restoreAllImages();
state.topSpacer = null;
state.bottomSpacer = null;
state.lastStart = -1;
state.lastEnd = -1;
state.framePending = false;
state.active = false;
const currentMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
setViewMode(currentMode);
}
function syncVirtualizationWithViewport() {
if (!Array.isArray(state.items) || state.items.length === 0) {
state.items = Array.from(container.querySelectorAll('.library-item'));
}
const shouldVirtualize = isMobileViewport() && state.items.length > MOBILE_LIBRARY_MIN_ITEMS;
if (shouldVirtualize) {
activateVirtualization();
scheduleWindowRender();
} else {
deactivateVirtualization();
}
}
if (!state.initialized) {
state.resizeListener = () => syncVirtualizationWithViewport();
window.addEventListener('resize', state.resizeListener, { passive: true });
state.initialized = true;
}
syncVirtualizationWithViewport();
}
// Initialize view mode
document.addEventListener('DOMContentLoaded', function() {
const savedMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
setViewMode(savedMode);
document.getElementById('viewModeToggle').addEventListener('click', function() {
const currentMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
const newMode = currentMode === 'card' ? 'table' : 'card';
setViewMode(newMode);
});
// Details button handlers
document.querySelectorAll('.details-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
e.preventDefault();
const itemId = this.dataset.itemId;
fetch(`/get_item_details/${itemId}`)
.then(r => r.json())
.then(data => {
if (data.success) {
displayLibraryItemDetail(data.item);
document.getElementById('detailModal').style.display = 'flex';
}
});
});
});
// Borrow button handlers
document.querySelectorAll('.borrow-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.getElementById('borrowItemId').value = this.dataset.itemId;
document.getElementById('borrowModal').style.display = 'flex';
});
});
// Borrow form submit
document.getElementById('borrowForm').addEventListener('submit', function(e) {
e.preventDefault();
const itemId = document.getElementById('borrowItemId').value;
const studentId = document.getElementById('studentIdInput').value || null;
const borrowDays = document.getElementById('borrowDaysInput').value || 14;
fetch('/borrow_item', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
item_id: itemId,
student_id: studentId,
borrow_days: borrowDays
})
})
.then(r => r.json())
.then(data => {
if (data.success) {
alert('Erfolgreich ausgeliehen!');
location.reload();
} else {
alert('Fehler: ' + data.message);
}
});
});
// Modal close handlers
document.querySelectorAll('.modal-close').forEach(btn => {
btn.addEventListener('click', function() {
this.closest('.modal').style.display = 'none';
});
});
// Close modals on outside click
document.querySelectorAll('.modal').forEach(modal => {
modal.addEventListener('click', function(e) {
if (e.target === this) this.style.display = 'none';
});
});
initMobileWindowedLibraryLoading();
});
function displayLibraryItemDetail(item) {
const modalBody = document.getElementById('modalBody');
const returnBtn = item.Verfuegbar === false ? `<button class="action-button return-btn" onclick="returnItem('${item._id}')">Zurückgeben</button>` : '';
const borrowBtn = item.Verfuegbar === true ? `<button class="action-button borrow-btn" onclick="openBorrowModal('${item._id}')">Ausleihen</button>` : '';
modalBody.innerHTML = `
<h2>${item.Name}</h2>
<div class="detail-content">
${item.Image ? `<img src="/uploads/${item.Image}" alt="${item.Name}" style="max-width: 200px; margin-bottom: 20px;">` : ''}
<p><strong>Autor:</strong> ${item.Author || '—'}</p>
<p><strong>ISBN:</strong> ${item.ISBN || '—'}</p>
<p><strong>Typ:</strong> ${item.ItemType || 'Medium'}</p>
<p><strong>Status:</strong> ${item.Verfuegbar === false ? 'Ausgeliehen' : 'Verfügbar'}</p>
${item.User ? `<p><strong>Ausgeliehen von:</strong> ${item.User}</p>` : ''}
${item.Beschreibung ? `<p><strong>Beschreibung:</strong> ${item.Beschreibung}</p>` : ''}
<div class="modal-actions">
${borrowBtn}
${returnBtn}
</div>
</div>
`;
}
function openBorrowModal(itemId) {
document.getElementById('borrowItemId').value = itemId;
document.getElementById('borrowModal').style.display = 'flex';
document.getElementById('detailModal').style.display = 'none';
}
function returnItem(itemId) {
if (confirm('Wirklich zurückgeben?')) {
fetch('/return_item', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({item_id: itemId})
})
.then(r => r.json())
.then(data => {
if (data.success) {
alert('Erfolgreich zurückgegeben!');
location.reload();
}
});
}
}
// Filter dropdown toggle with auto-close
function closeAllFilters() {
document.querySelectorAll('.filter-dropdown').forEach(d => d.style.display = 'none');
document.getElementById('filterBtn').setAttribute('aria-expanded', 'false');
document.getElementById('filterBtn').classList.remove('open');
}
document.getElementById('filterBtn').addEventListener('click', function(e) {
e.stopPropagation();
const dropdown = document.getElementById('filterDropdown');
const isOpen = dropdown.style.display !== 'none';
closeAllFilters();
if (!isOpen) {
dropdown.style.display = 'block';
this.setAttribute('aria-expanded', 'true');
this.classList.add('open');
}
});
document.addEventListener('click', closeAllFilters);
document.getElementById('filterDropdown').addEventListener('click', e => e.stopPropagation());
// Clear filters
document.getElementById('clearFiltersBtn').addEventListener('click', function() {
document.querySelectorAll('.filter-checkbox').forEach(c => c.checked = false);
closeAllFilters();
});
// Scanner toggle
// Scanner state tracking
let isScanning = false;
document.getElementById('scannerBtn').addEventListener('click', function(e) {
e.stopPropagation();
const container = document.getElementById('qrContainer');
const isOpen = container.style.display !== 'none';
const btn = this;
if (isOpen) {
// Close the scanner
container.style.display = 'none';
Quagga.stop();
isScanning = false;
btn.classList.remove('open');
btn.setAttribute('aria-expanded', 'false');
} else {
// Open the scanner
container.style.display = 'block';
btn.classList.add('open');
btn.setAttribute('aria-expanded', 'true');
Quagga.init({
inputStream: {
name: "Live",
type: "LiveStream",
// Targets the element where the video stream will inject
target: document.querySelector('#qr-reader'),
constraints: {
width: 640,
height: 480,
facingMode: "environment" // Forces back camera
},
},
decoder: {
// Optimized for 1D barcodes (e.g., student IDs, member cards)
readers: ["code_128_reader", "ean_reader", "code_39_reader", "upc_reader"]
}
}, function(err) {
if (err) {
console.error("Initialization error:", err);
alert("Kamera konnte nicht gestartet werden.");
return;
}
Quagga.start();
isScanning = true;
});
}
});
// Single Quagga event listener for successful scans
Quagga.onDetected(function(data) {
const decodedText = data.codeResult.code;
const studentId = String(decodedText || '').trim();
// Stop scanning immediately to prevent multiple triggers
Quagga.stop();
isScanning = false;
// Reset UI states to closed
const container = document.getElementById('qrContainer');
const btn = document.getElementById('scannerBtn');
if (container) container.style.display = 'none';
if (btn) {
btn.classList.remove('open');
btn.setAttribute('aria-expanded', 'false');
}
// Route the scanned data to your input
document.getElementById('studentIdInput').value = studentId;
alert('Ausweis gescannt: ' + studentId);
});
</script>
<style>
.library-item .item-image-wrapper {
width: 120px;
height: 160px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
}
.library-item .placeholder-image {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
color: white;
}
.library-item .item-author {
font-size: 0.9rem;
color: #666;
margin: 4px 0;
}
.library-item .item-isbn {
font-size: 0.85rem;
color: #999;
font-family: monospace;
margin: 4px 0;
}
.library-item .item-type {
display: inline-block;
background: #e8f0fe;
color: #1967d2;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.8rem;
}
.library-item .item-status.available {
background: #e6f4ea;
color: #137333;
}
.library-item .item-status.borrowed {
background: #fce8e6;
color: #b3261e;
}
/* Mobile: allow horizontal scrolling for table-mode views */
@media (max-width: 900px) {
.library-table-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
width: 100%;
}
.library-table-wrapper .table-row {
min-width: 720px; /* allow table to have intrinsic width and be scrolled */
}
.table-header, .item-content.table-mode {
display: block; /* keep rows stacked but allow horizontal scroll */
}
}
</style>
{% endblock %}
+261 -258
View File
@@ -52,9 +52,10 @@
/* The Scrollable Content Area */ /* The Scrollable Content Area */
#detailContent { #detailContent {
overflow-y: auto; /* Adds scrollbar only if needed */ overflow-y: auto;
padding-right: 10px; /* Prevents text from rubbing against the scrollbar */ padding-right: 10px;
} }
/* Library table-only view styles */ /* Library table-only view styles */
.library-table-container { .library-table-container {
max-width: 1400px; max-width: 1400px;
@@ -276,6 +277,7 @@
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
color: #555; color: #555;
font-size: 0.95em; font-size: 0.95em;
vertical-align: middle;
} }
.library-items-table tbody tr:hover { .library-items-table tbody tr:hover {
@@ -493,21 +495,22 @@
<div class="library-table-container" id="libraryTableContainer" data-can-edit="{{ 1 if current_permissions.actions.get('can_edit', False) else 0 }}"> <div class="library-table-container" id="libraryTableContainer" data-can-edit="{{ 1 if current_permissions.actions.get('can_edit', False) else 0 }}">
<!-- Header --> <!-- Header -->
<!-- Search and Filter Toggle -->
<!-- Customizable Filter Panel -->
<div class="library-header"> <div class="library-header">
<h1>📚 Bibliothek</h1> <h1>📚 Bibliothek</h1>
<p>Bücher, CDs, DVDs und weitere Medien</p> <p>Bücher, CDs, DVDs und weitere Medien</p>
</div> </div>
<!-- Student card / quick scan workflow -->
<!-- Search and Filter Toggle -->
<div class="library-search-bar"> <div class="library-search-bar">
<input <input
type="text" type="text"
id="librarySearch" id="librarySearch"
class="library-search-input" class="library-search-input"
placeholder="Nach Titel, ISBN suchen..." placeholder="Nach Titel, ISBN suchen..."
> >
<button <button
id="filterToggleBtn" id="filterToggleBtn"
class="library-filter-toggle-btn" class="library-filter-toggle-btn"
aria-label="Filter öffnen/schließen" aria-label="Filter öffnen/schließen"
> >
@@ -515,37 +518,22 @@
</button> </button>
</div> </div>
<!-- Customizable Filter Panel --> <div class="library-scan-controls">
<select id="scanModeSelect" aria-label="Scan-Modus">
<!-- Student card / quick scan workflow --> <option value="card_only">Nur Ausweis erfassen</option>
<div class="library-scan-panel"> <option value="quick_toggle">Schnellmodus: Ausweis + Mediencode</option>
<div class="library-scan-controls"> <option value="continuous">Dauermodus: 1x Ausweis, dann N Medien</option>
<select id="scanModeSelect" aria-label="Scan-Modus"> <option value="return_only">Nur Rückgabe (nur Mediencode)</option>
<option value="card_only">Nur Ausweis erfassen</option> </select>
<option value="quick_toggle">Schnellmodus: Ausweis + Mediencode</option> <input type="text" id="activeStudentCard" placeholder="Aktiver Ausweis (gescannt)">
</select> <input type="text" id="manualItemCode" placeholder="Manueller Mediencode (optional)" style="min-width:180px;">
<input type="text" id="activeStudentCard" placeholder="Aktiver Ausweis (gescannt)" > <button id="resetCardBtn" class="button" type="button">Ausweis löschen</button>
<input type="text" id="manualItemCode" placeholder="Manueller Mediencode (optional)" style="min-width:180px;"> <button id="toggleScannerBtn" class="button" type="button">Scanner starten</button>
<button id="resetCardBtn" class="button" type="button">Ausweis löschen</button> <label style="display:flex; align-items:center; gap:8px; margin-left:6px;">
<button id="toggleScannerBtn" class="button" type="button">Scanner starten</button> <input type="checkbox" id="keyboardScannerToggle">
<label style="display:flex; align-items:center; gap:8px; margin-left:6px;"> <span style="font-size:0.9em;">Physischer Scanner</span>
<input type="checkbox" id="keyboardScannerToggle"> </label>
<span style="font-size:0.9em;">Physischer Scanner</span> <button id="manualActionBtn" class="button" type="button" style="margin-left:6px; background:#4f46e5; color:white;">Code verarbeiten</button>
</label>
<label style="display:flex; align-items:center; gap:8px; margin-left:6px;">
<input type="checkbox" id="returnOnlyToggle">
<span style="font-size:0.9em;">Nur Rückgabe (nur Mediencode)</span>
</label>
<button id="manualReturnBtn" class="button" type="button" style="margin-left:6px; background:#10b981;color:white;">Rückgabe per Code</button>
</div>
<div id="scanStatus" class="library-scan-status">
Hinweis: Im Schnellmodus zuerst den Schülerausweis scannen, danach den Buch-/Mediencode.
</div>
<div id="scanReaderWrap" class="library-scan-reader-wrap" style="display: none;">
<div id="libraryQrReader" class="library-scan-reader">
<div id="library-scanner-container"></div>
</div>
</div>
</div> </div>
<div id="filterPanel" class="library-filter-panel"> <div id="filterPanel" class="library-filter-panel">
<div class="filter-row"> <div class="filter-row">
@@ -598,12 +586,12 @@
<table class="library-items-table"> <table class="library-items-table">
<thead> <thead>
<tr> <tr>
<th style="width: 24%;">Titel</th> <th style="width: 32%;">Titel</th>
<th style="width: 12%;">ISBN/Code</th> <th style="width: 12%;">ISBN/Code</th>
<th style="width: 8%;">Typ</th> <th style="width: 8%;">Typ</th>
<th style="width: 8%;">Anzahl</th> <th style="width: 8%;">Anzahl</th>
<th style="width: 12%;">Status</th> <th style="width: 14%;">Status</th>
<th style="width: 22%;">Aktionen</th> <th style="width: 26%;">Aktionen</th>
</tr> </tr>
</thead> </thead>
<tbody id="itemsTableBody"> <tbody id="itemsTableBody">
@@ -656,11 +644,11 @@
const RENDER_BATCH_COUNT = 120; const RENDER_BATCH_COUNT = 120;
let renderedCount = INITIAL_RENDER_COUNT; let renderedCount = INITIAL_RENDER_COUNT;
let filterPanelOpen = false; let filterPanelOpen = false;
// Scanner Related State Variables // Scanner Related State Variables
let scannerInstance = null; let scannerInstance = null;
let scannerRunning = false; let scannerRunning = false;
let activeScannerCallback = null; let activeScannerCallback = null;
let activeStudentCardId = ''; let activeStudentCardId = '';
let lastScanValue = ''; let lastScanValue = '';
let lastScanAt = 0; let lastScanAt = 0;
@@ -674,9 +662,14 @@
seriesGroupId: '', seriesGroupId: '',
groupMembers: [] groupMembers: []
}; };
const canEditLibraryItems = (document.getElementById('libraryTableContainer')?.dataset.canEdit === '1'); const canEditLibraryItems = (document.getElementById('libraryTableContainer')?.dataset.canEdit === '1');
function isVideoFile(filename) {
if (!filename) return false;
return /\.(mp4|webm|ogg|mov)$/i.test(filename);
}
// ========================================================================= // =========================================================================
// 2. DATA LOADING & FILTERING ENGINE // 2. DATA LOADING & FILTERING ENGINE
// ========================================================================= // =========================================================================
@@ -705,7 +698,7 @@
} }
} catch (error) { } catch (error) {
console.error('Error loading library items:', error); console.error('Error loading library items:', error);
document.getElementById('itemsTableBody').innerHTML = '<tr><td colspan="7" style="text-align:center; color:#999;">Fehler beim Laden der Bibliothekselemente.</td></tr>'; document.getElementById('itemsTableBody').innerHTML = '<tr><td colspan="8" style="text-align:center; color:#999;">Fehler beim Laden der Bibliothekselemente.</td></tr>';
} finally { } finally {
pagingState.loading = false; pagingState.loading = false;
} }
@@ -797,6 +790,7 @@
const statusText = statusKey === 'damaged' ? 'Defekt/Zerstört' : (statusKey === 'borrowed' ? 'Ausgeliehen' : 'Verfügbar'); const statusText = statusKey === 'damaged' ? 'Defekt/Zerstört' : (statusKey === 'borrowed' ? 'Ausgeliehen' : 'Verfügbar');
const actionLabel = statusKey === 'available' ? 'Ausleihen' : (statusKey === 'borrowed' ? 'Reservieren' : 'Nicht ausleihbar'); const actionLabel = statusKey === 'available' ? 'Ausleihen' : (statusKey === 'borrowed' ? 'Reservieren' : 'Nicht ausleihbar');
const actionDisabled = statusKey === 'damaged' ? 'disabled' : ''; const actionDisabled = statusKey === 'damaged' ? 'disabled' : '';
return ` return `
<tr> <tr>
<td class="table-title">${escapeHtml(item.Name || 'Untitled')}</td> <td class="table-title">${escapeHtml(item.Name || 'Untitled')}</td>
@@ -877,33 +871,33 @@
function startScanner(targetCallback) { function startScanner(targetCallback) {
const readerWrap = document.getElementById('scanReaderWrap'); const readerWrap = document.getElementById('scanReaderWrap');
const toggleBtn = document.getElementById('toggleScannerBtn'); const toggleBtn = document.getElementById('toggleScannerBtn');
activeScannerCallback = targetCallback; activeScannerCallback = targetCallback;
if (readerWrap) readerWrap.style.display = 'block'; if (readerWrap) readerWrap.style.display = 'block';
setScanStatus('Initializing camera...', 'warn'); setScanStatus('Initializing camera...', 'warn');
Quagga.init({ Quagga.init({
inputStream: { inputStream: {
name: "Live", name: "Live",
type: "LiveStream", type: "LiveStream",
target: document.querySelector('#library-scanner-container'), target: document.querySelector('#library-scanner-container'),
constraints: { constraints: {
width: 640, width: 640,
height: 480, height: 480,
facingMode: "environment" facingMode: "environment"
}, },
}, },
decoder: { decoder: {
readers: [ readers: [
"code_128_reader", "code_128_reader",
"ean_reader", "ean_reader",
"code_39_reader", "code_39_reader",
"upc_reader", "upc_reader",
"codabar_reader", "codabar_reader",
"i2of5_reader" "i2of5_reader"
] ]
} }
}, function(err) { }, function(err) {
if (err) { if (err) {
console.error('Scanner start failed:', err); console.error('Scanner start failed:', err);
@@ -912,47 +906,66 @@
setScanStatus(`Scanner konnte nicht gestartet werden${detail}`, 'error'); setScanStatus(`Scanner konnte nicht gestartet werden${detail}`, 'error');
return; return;
} }
Quagga.start(); Quagga.start();
scannerRunning = true; scannerRunning = true;
if (!targetCallback && toggleBtn) { if (!targetCallback && toggleBtn) {
toggleBtn.textContent = 'Scanner stoppen'; toggleBtn.textContent = 'Scanner stoppen';
} }
setScanStatus('Scanner aktiv. Jetzt Code scannen.', 'warn'); setScanStatus('Scanner aktiv. Jetzt Code scannen.', 'warn');
}); });
} }
function stopScanner() { function stopScanner() {
if (!scannerRunning) return; if (!scannerRunning) return;
const readerWrap = document.getElementById('scanReaderWrap'); const readerWrap = document.getElementById('scanReaderWrap');
const toggleBtn = document.getElementById('toggleScannerBtn'); const toggleBtn = document.getElementById('toggleScannerBtn');
Quagga.stop(); Quagga.stop();
scannerRunning = false; scannerRunning = false;
activeScannerCallback = null; activeScannerCallback = null;
if (readerWrap) readerWrap.style.display = 'none'; if (readerWrap) readerWrap.style.display = 'none';
if (toggleBtn) toggleBtn.textContent = 'Scanner starten'; if (toggleBtn) toggleBtn.textContent = 'Scanner starten';
setScanStatus('Scanner gestoppt.', 'warn'); setScanStatus('Scanner gestoppt.', 'warn');
} }
async function isStudentCardBarcode(code) {
if (!code) return false;
try {
const response = await fetch(`/api/is_student_card/${encodeURIComponent(code)}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
// Falls du in Flask CSRF-Protect nutzt, muss der Token mitgesendet werden:
'X-CSRFToken': '{{ csrf_token }}',
'X-CSRF-Token': '{{ csrf_token }}'
}
});
if (!response.ok) return false;
// Wir parsen die Antwort. Flask jsonify({"is_student_card": true/false})
const data = await response.json();
return data === true || data.is_student_card === true;
} catch (error) {
console.error('Fehler bei der API-Prüfung des Ausweises:', error);
return false;
}
}
Quagga.onDetected(function(data) { Quagga.onDetected(function(data) {
if (!data || !data.codeResult || !data.codeResult.code) return; if (!data || !data.codeResult || !data.codeResult.code) return;
const barcode = String(data.codeResult.code || '').trim(); const barcode = String(data.codeResult.code || '').trim();
console.log("Barcode detected:", barcode); console.log("Barcode detected:", barcode);
const currentCallback = activeScannerCallback; const currentCallback = activeScannerCallback;
stopScanner(); stopScanner();
const returnOnly = (document.getElementById('returnOnlyToggle') || {}).checked;
if (returnOnly) {
// direct return flow
returnByCode(barcode);
return;
}
if (typeof currentCallback === "function") { if (typeof currentCallback === "function") {
currentCallback(barcode); currentCallback(barcode);
@@ -980,14 +993,8 @@
keyboardScanBuffer = ''; keyboardScanBuffer = '';
keyboardLastKeyAt = 0; keyboardLastKeyAt = 0;
if (!code) return; if (!code) return;
// If return-only mode is active, attempt direct return
const returnOnly = (document.getElementById('returnOnlyToggle') || {}).checked;
if (returnOnly) {
returnByCode(code);
return;
}
// Process exactly like a camera scan // Process exactly like a camera scan - centralized logic handles the rest!
handleScanSuccess(code); handleScanSuccess(code);
return; return;
} }
@@ -1006,33 +1013,123 @@
} }
function handleScanSuccess(decodedText) { function handleScanSuccess(decodedText) {
const scannedCode = normalizeScannedCode(decodedText); const scannedCode = typeof normalizeScannedCode === "function" ? normalizeScannedCode(decodedText) : decodedText;
if (!scannedCode) return; if (!scannedCode) return;
const now = Date.now(); const now = Date.now();
if (scannedCode === lastScanValue && (now - lastScanAt) < 1500) { if (scannedCode === lastScanValue && (now - lastScanAt) < 1500) {
return; return; // Verhindert doppeltes Scannen in kurzer Zeit
} }
lastScanValue = scannedCode; lastScanValue = scannedCode;
lastScanAt = now; lastScanAt = now;
// Den aktuellen Modus aus dem Dropdown auslesen
const mode = (document.getElementById('scanModeSelect') || {}).value || 'card_only'; const mode = (document.getElementById('scanModeSelect') || {}).value || 'card_only';
// 1. Modus: Nur Rückgabe
if (mode === 'return_only') {
returnByCode(scannedCode);
return;
}
// 2. Modus: Nur Ausweis
if (mode === 'card_only') { if (mode === 'card_only') {
setActiveStudentCard(scannedCode); setActiveStudentCard(scannedCode);
setScanStatus(`Ausweis gesetzt: ${activeStudentCardId}`, 'ok'); setScanStatus(`Ausweis gesetzt: ${activeStudentCardId}`, 'ok');
return; return;
} }
processQuickToggleScan(scannedCode); // 3. Modus: Schnellmodus (1x Ausweis, 1x Buch)
if (mode === 'quick_toggle') {
processQuickToggleScan(scannedCode);
return;
}
// 4. Modus: Dauermodus (1x Ausweis, Nx Bücher)
if (mode === 'continuous') {
processContinuousScan(scannedCode);
return;
}
}
async function processContinuousScan(scannedCode) {
setScanStatus('Überprüfe Code-Typ...', 'warn');
const isCard = await isStudentCardBarcode(scannedCode);
if (isCard) {
setActiveStudentCard(scannedCode);
setScanStatus(`Neuer Ausweis gesetzt: ${activeStudentCardId}. Bitte Medien scannen.`, 'ok');
showSmallConfirm(`Benutzer gewechselt zu: ${activeStudentCardId}`, 'ok');
setTimeout(() => {
if (document.getElementById('scanModeSelect').value === 'continuous') {
startScanner();
}
}, 1000);
return;
}
if (!activeStudentCardId) {
setScanStatus('Kein Ausweis aktiv! Bitte zuerst einen Schülerausweis scannen.', 'error');
showSmallConfirm('Bitte zuerst Ausweis scannen', 'error');
setTimeout(() => {
if (document.getElementById('scanModeSelect').value === 'continuous') {
startScanner();
}
}, 2000);
return;
}
try {
setScanStatus('Verarbeite Mediencode...', 'warn');
const response = await fetch('/api/library_scan_action', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}',
'X-CSRF-Token': '{{ csrf_token }}'
},
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');
showSmallConfirm(result.message || 'Aktion fehlgeschlagen.', 'error');
} else if (result.action === 'borrowed') {
setScanStatus(`Ausgeliehen: ${result.item_name}`, 'ok');
showSmallConfirm(`Ausgeliehen: ${result.item_name}`, 'ok');
} else if (result.action === 'returned') {
setScanStatus(`Zurückgegeben: ${result.item_name}`, 'ok');
showSmallConfirm(`Zurückgegeben: ${result.item_name}`, 'ok');
} else {
setScanStatus(result.message || 'Aktion durchgeführt.', 'ok');
showSmallConfirm(result.message || 'Erfolgreich', 'ok');
}
// Tabellen-Ansicht aktualisieren
if (typeof loadLibraryItems === "function") {
await loadLibraryItems();
}
} catch (err) {
console.error('Continuous scan action failed:', err);
setScanStatus('Fehler beim Verarbeiten des Scans.', 'error');
showSmallConfirm('Fehler im Netzwerk/System', 'error');
}
setTimeout(() => {
if (document.getElementById('scanModeSelect').value === 'continuous') {
startScanner();
}
}, 1500);
} }
async function processQuickToggleScan(scannedCode) { async function processQuickToggleScan(scannedCode) {
// 1. Prüfen, ob "Nur Rückgabe"-Modus aktiv ist
const returnOnly = (document.getElementById('returnOnlyToggle') || {}).checked;
if (returnOnly) {
await returnByCode(scannedCode);
return;
}
// 2. Wenn kein Ausweis gesetzt ist, wird der Code als Ausweis interpretiert // 2. Wenn kein Ausweis gesetzt ist, wird der Code als Ausweis interpretiert
if (!activeStudentCardId) { if (!activeStudentCardId) {
@@ -1140,9 +1237,9 @@
} }
const defaultCardId = activeStudentCardId || ''; const defaultCardId = activeStudentCardId || '';
const cardId = (window.prompt('Bitte Schülerausweis-ID eingeben:', defaultCardId) || '').trim().toUpperCase(); const cardId = (window.prompt('Bitte Bibliotheksausweis-ID eingeben:', defaultCardId) || '').trim().toUpperCase();
if (!cardId) { if (!cardId) {
alert('Ausleihe abgebrochen: Für Bibliotheksmedien ist eine gültige Schülerausweis-ID erforderlich.'); alert('Ausleihe abgebrochen: Für Bibliotheksmedien ist eine gültige Bibliotheksausweis-ID erforderlich.');
return; return;
} }
@@ -1244,30 +1341,69 @@
return div.innerHTML; return div.innerHTML;
} }
// Opens the modal and fetches the data
function showItemDetail(itemId) { function showItemDetail(itemId) {
const detailContent = document.getElementById('detailContent'); const detailContent = document.getElementById('detailContent');
const detailModal = document.getElementById('detailModal'); const detailModal = document.getElementById('detailModal');
// 1. Show the loading state immediately detailContent.innerHTML = '<p>Lade Details...</p>';
detailContent.innerHTML = '<p>Loading details...</p>';
detailModal.style.display = 'flex'; detailModal.style.display = 'flex';
// 2. Fetch the data const item = libraryItems.find(i => i._id === itemId);
let mediaHtml = '';
// Robuste Prüfung: Wir testen gängige Benennungen aus deinem Backend
const imageArray = item.Images || item.Bilder || item.images;
if (item && Array.isArray(imageArray) && imageArray.length > 0) {
const imagesHtml = imageArray.map((image, index) => {
// Dein neuer Code für die exakte Routen-Generierung
const imageSrc = image.startsWith('/uploads/') || image.startsWith('http') ?
image :
`{{ url_for('uploaded_file', filename='') }}${image}`;
const thumbnailInfo = item.ThumbnailInfo && item.ThumbnailInfo[index];
const isVideo = isVideoFile(image);
if (isVideo) {
const videoSrc = thumbnailInfo && thumbnailInfo.has_thumbnail
? thumbnailInfo.thumbnail_url
: imageSrc;
if (thumbnailInfo && thumbnailInfo.has_thumbnail) {
return `
<div class="video-container" style="position: relative; width: 120px; height: 120px; display: inline-block; margin-right: 15px; margin-bottom: 15px;">
<img src="${videoSrc}" alt="${escapeHtml(item.Name || 'Medium')}" class="item-image" style="width: 100%; height: 100%; object-fit: cover; border-radius: 8px; border: 1px solid #ddd;">
<div class="video-preview-overlay" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: white; background: rgba(0,0,0,0.6); border-radius: 50%; width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; font-size: 16px;">
</div>
</div>`;
} else {
return `<div style="width: 120px; height: 120px; background: #333; color: #fff; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; margin-right: 15px; margin-bottom: 15px;">VIDEO</div>`;
}
} else {
const imageSrcFinal = thumbnailInfo && thumbnailInfo.has_thumbnail
? thumbnailInfo.thumbnail_url
: imageSrc;
return `<img src="${imageSrcFinal}" alt="${escapeHtml(item.Name || 'Medium')}" class="item-image" style="width: 120px; height: 120px; object-fit: cover; border-radius: 8px; border: 1px solid #ddd; margin-right: 15px; margin-bottom: 15px;">`;
}
}).join('');
mediaHtml = `<div class="detail-gallery-container" style="margin-bottom: 20px; padding-bottom: 15px; border-bottom: 1px solid #eee; display: flex; flex-wrap: wrap;">${imagesHtml}</div>`;
}
fetch(`/api/item_detail/${itemId}`) fetch(`/api/item_detail/${itemId}`)
.then(response => { .then(response => {
if (!response.ok) { if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.text(); return response.text();
}) })
.then(html => { .then(html => {
// 3. Clean the HTML and display it detailContent.innerHTML = mediaHtml + DOMPurify.sanitize(html);
detailContent.innerHTML = DOMPurify.sanitize(html);
}) })
.catch(err => { .catch(err => {
console.error('Error loading detail:', err); console.error('Error loading detail:', err);
detailContent.innerHTML = '<p>Sorry, we could not load the item details. Please try again later.</p>'; detailContent.innerHTML = '<p style="color: red;">Entschuldigung, die Details konnten nicht geladen werden.</p>';
}); });
} }
@@ -1314,7 +1450,7 @@
if (modeSelect) { if (modeSelect) {
modeSelect.addEventListener('change', () => { modeSelect.addEventListener('change', () => {
if (modeSelect.value === 'quick_toggle' && !activeStudentCardId) { if (modeSelect.value === 'quick_toggle' && !activeStudentCardId) {
setScanStatus('Schnellmodus: zuerst Schülerausweis scannen.', 'warn'); setScanStatus('Schnellmodus: zuerst Bibliotheksausweis scannen.', 'warn');
} else if (modeSelect.value === 'card_only') { } else if (modeSelect.value === 'card_only') {
setScanStatus('Nur Ausweis-Modus aktiv.', 'warn'); setScanStatus('Nur Ausweis-Modus aktiv.', 'warn');
} }
@@ -1387,111 +1523,30 @@
}); });
} }
const manualReturnBtn = document.getElementById('manualReturnBtn'); const manualActionBtn = document.getElementById('manualActionBtn');
const manualItemCode = document.getElementById('manualItemCode'); const manualItemCode = document.getElementById('manualItemCode');
if (manualReturnBtn && manualItemCode) {
manualReturnBtn.addEventListener('click', async () => { if (manualActionBtn && manualItemCode) {
manualActionBtn.addEventListener('click', () => {
const code = (manualItemCode.value || '').trim(); const code = (manualItemCode.value || '').trim();
if (!code) { if (!code) {
alert('Bitte einen Mediencode eingeben.'); alert('Bitte einen Code eingeben.');
return; return;
} }
await returnByCode(code);
// Leitet den manuellen Code an die zentrale Logik weiter,
// die prüft, welcher Modus im Dropdown aktiv ist.
handleScanSuccess(code);
// Feld nach Eingabe leeren, um direkt den nächsten Code bereitzuhaben
manualItemCode.value = '';
}); });
}
const editForm = document.getElementById('editLibraryForm'); // Optional: Auch auf "Enter" im Textfeld reagieren
if (editForm) { manualItemCode.addEventListener('keypress', (e) => {
editForm.addEventListener('submit', async function (e) { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
manualActionBtn.click();
const itemId = document.getElementById('editLibraryItemId').value;
const currentItem = libraryItems.find(i => i._id === itemId);
if (!currentItem) return;
const codeInputs = Array.from(document.querySelectorAll('#editLibraryCodesContainer input[data-item-id]'));
// Daten aus dem Formular sammeln
const sharedPayload = {
name: document.getElementById('editLibraryName').value,
item_type: document.getElementById('editLibraryType').value,
isbn: document.getElementById('editLibraryIsbn').value,
ort: document.getElementById('editLibraryLocation').value,
beschreibung: document.getElementById('editLibraryDescription').value,
ansch_jahr: currentItem.Anschaffungsjahr || '',
ansch_kost: currentItem.Anschaffungskosten || '',
reservierbar: currentItem.Reservierbar !== false,
};
const codeByItemId = new Map(codeInputs.map(input => [input.dataset.itemId, (input.value || '').trim()]));
const groupMembers = editLibraryState.groupMembers.length > 0 ? editLibraryState.groupMembers : [currentItem];
const isGroupedEdit = Boolean(currentItem.SeriesGroupId) && groupMembers.length > 1;
// API-Aufruf
try {
if (isGroupedEdit) {
const payload = {
series_group_id: currentItem.SeriesGroupId,
...sharedPayload,
items: groupMembers.map(member => ({
id: member._id,
code_4: codeByItemId.get(member._id) || ''
}))
};
const response = await fetch('/update_group', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}',
'X-CSRF-Token': '{{ csrf_token }}'
},
body: JSON.stringify(payload)
});
const result = await response.json();
if (response.ok && result.success) {
alert(result.message || 'Gruppe erfolgreich aktualisiert!');
closeEditLibraryModal();
pagingState.loading = false;
await loadLibraryItems();
} else {
alert(result.message || 'Fehler beim Speichern der Gruppenänderungen.');
}
} else {
const primaryCodeInput = codeInputs[0];
const payload = {
name: sharedPayload.name,
item_type: sharedPayload.item_type,
isbn: sharedPayload.isbn,
code_4: primaryCodeInput ? primaryCodeInput.value.trim() : (currentItem.Code_4 || currentItem.Code4 || '').trim(),
ort: sharedPayload.ort,
beschreibung: sharedPayload.beschreibung
};
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(payload)
});
const result = await response.json();
if (response.ok && result.ok) {
alert(result.message || 'Medium erfolgreich aktualisiert!');
closeEditLibraryModal();
pagingState.loading = false;
await loadLibraryItems();
} else {
alert(result.message || 'Fehler beim Speichern der Änderungen.');
}
}
} catch (error) {
console.error('Update failed:', error);
alert('Netzwerkfehler beim Aktualisieren des Mediums.');
} }
}); });
} }
@@ -1513,58 +1568,6 @@
} }
} }
function renderLibraryGroupCodeFields(groupMembers, currentItemId) {
const codesContainer = document.getElementById('editLibraryCodesContainer');
const groupWarning = document.getElementById('editLibraryGroupWarning');
const groupCount = document.getElementById('editLibraryGroupCount');
const groupHint = document.getElementById('editLibraryGroupHint');
if (!codesContainer) return;
const items = Array.isArray(groupMembers) ? groupMembers.slice() : [];
items.sort((a, b) => (a.SeriesPosition || 0) - (b.SeriesPosition || 0) || String(a.Name || '').localeCompare(String(b.Name || '')));
editLibraryState.groupMembers = items;
if (groupWarning) {
groupWarning.style.display = items.length > 1 ? 'block' : 'none';
}
if (groupCount) {
const totalCount = items.length || 1;
const declaredCount = items[0]?.SeriesCount || totalCount;
groupCount.textContent = `${totalCount} / ${declaredCount}`;
}
if (groupHint) {
groupHint.textContent = items.length > 1
? 'Jeder Code gehört zu einem eigenen Exemplar. Änderungen werden für alle Codes gespeichert.'
: 'Einzelnes Exemplar. Der Code wird direkt gespeichert.';
}
if (!items.length) {
codesContainer.innerHTML = '<div style="padding:10px 0; color:#6b7280;">Keine Codes geladen.</div>';
return;
}
codesContainer.innerHTML = items.map((member, index) => {
const codeValue = member.Code_4 || member.Code4 || '';
const labelParts = [];
if (member.SeriesPosition !== undefined && member.SeriesPosition !== null) {
labelParts.push(`Exemplar ${member.SeriesPosition}`);
} else {
labelParts.push(`Exemplar ${index + 1}`);
}
if (member._id === currentItemId) {
labelParts.push('aktuelles Medium');
}
return `
<div style="display:flex; flex-direction:column; gap:6px; margin-bottom:10px;">
<label for="editLibraryCode-${member._id}" style="font-weight:600; font-size:0.9em; color:var(--ui-text);">${escapeHtml(labelParts.join(' · '))}</label>
<input id="editLibraryCode-${member._id}" data-item-id="${escapeHtml(member._id)}" value="${escapeHtml(codeValue)}" placeholder="Mediencode" style="width:100%; padding:8px 10px; border:1px solid #d0d7e2; border-radius:6px;">
</div>
`;
}).join('');
}
function openEditLibraryItem(itemId) { function openEditLibraryItem(itemId) {
window.location.href = `/item_edit/${itemId}`; window.location.href = `/item_edit/${itemId}`;
} }
+463 -335
View File
@@ -6,7 +6,7 @@
<div class="container"> <div class="container">
<div class="header-section"> <div class="header-section">
<h1>Neuen Benutzer registrieren</h1> <h1>Neuen Benutzer registrieren</h1>
<p class="subtitle">Erstellen Sie ein neues Benutzerkonto und legen Sie Zugriffsrechte fest</p> <p class="subtitle">Erstellen Sie ein neues Benutzerkonto oder importieren Sie mehrere Benutzer per CSV</p>
</div> </div>
<div class="flash-container"> <div class="flash-container">
@@ -23,7 +23,43 @@
</div> </div>
<div class="content"> <div class="content">
<!-- 1. CSV BULK IMPORT CARD -->
<div class="form-card" style="margin-bottom: 2rem; border-top: 4px solid #059669;">
<div class="card-header" style="margin-bottom: 1rem;">
<h2>Massenregistrierung via CSV</h2>
<p class="subtitle" style="color: #4b5563;">
Laden Sie eine CSV-Datei hoch (Format: <code>Vorname, Nachname</code>).
Benutzernamen und sichere Passwörter werden serverseitig generiert. Nach dem Upload erhalten Sie direkt ein PDF mit Zugangsdaten (2 pro Seite zum Ausschneiden).
</p>
</div>
<form method="POST" action="{{ url_for('register_csv') }}" enctype="multipart/form-data">
<div class="form-group">
<label for="permission-preset-csv">Berechtigungs-Preset für alle CSV-Benutzer</label>
<select id="permission-preset-csv" name="permission_preset" class="form-select" style="margin-bottom: 1rem;">
{% for preset_key, preset in permission_presets.items() %}
<option value="{{ preset_key }}">{{ preset.label }}</option>
{% endfor %}
</select>
<label for="csv_file">CSV-Datei auswählen</label>
<div class="input-container">
<span class="input-icon">📄</span>
<input type="file" id="csv_file" name="csv_file" accept=".csv" required style="padding: 10px;">
</div>
</div>
<div class="form-group form-actions" style="margin-top: 1.5rem;">
<button type="submit" class="action-button" style="background-color: #059669; color: white;">
📥 CSV Importieren & Zugangsdaten-PDF Herunterladen
</button>
</div>
</form>
</div>
<!-- 2. SINGLE USER REGISTRATION CARD -->
<div class="form-card"> <div class="form-card">
<h2>Einzelnen Benutzer registrieren</h2>
<form method="POST" action="{{ url_for('register') }}"> <form method="POST" action="{{ url_for('register') }}">
<div class="form-group"> <div class="form-group">
@@ -32,11 +68,13 @@
<span class="input-icon">👤</span> <span class="input-icon">👤</span>
<input type="text" id="name" name="name" placeholder="Geben Sie den Vornamen ein" required onchange="generateUsername()" oninput="generateUsername()"> <input type="text" id="name" name="name" placeholder="Geben Sie den Vornamen ein" required onchange="generateUsername()" oninput="generateUsername()">
</div> </div>
<label for="last-name">Nachname</label> <label for="last-name">Nachname</label>
<div class="input-container"> <div class="input-container">
<span class="input-icon">👤</span> <span class="input-icon">👤</span>
<input type="text" id="last-name" name="last-name" placeholder="Geben Sie den Nachnamen ein" required onchange="generateUsername()" oninput="generateUsername()"> <input type="text" id="last-name" name="last-name" placeholder="Geben Sie den Nachnamen ein" required onchange="generateUsername()" oninput="generateUsername()">
</div> </div>
<label for="username">Benutzername <span style="color: #9ca3af;">(Vorschau - wird serverseitig finalisiert)</span></label> <label for="username">Benutzername <span style="color: #9ca3af;">(Vorschau - wird serverseitig finalisiert)</span></label>
<div class="input-container"> <div class="input-container">
<span class="input-icon">👤</span> <span class="input-icon">👤</span>
@@ -44,7 +82,7 @@
</div> </div>
<p class="anonymize-hint">Klarnamen werden nur zur Erzeugung des Benutzernamens als Kürzel (z.B. SimFri) verwendet; bei Kollision wird automatisch ein Buchstabe mehr genommen.</p> <p class="anonymize-hint">Klarnamen werden nur zur Erzeugung des Benutzernamens als Kürzel (z.B. SimFri) verwendet; bei Kollision wird automatisch ein Buchstabe mehr genommen.</p>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">Passwort</label> <label for="password">Passwort</label>
<div class="password-rules" id="password-rules" aria-live="polite"> <div class="password-rules" id="password-rules" aria-live="polite">
@@ -57,20 +95,21 @@
<li id="pw-rule-symbol" class="pw-rule">Mindestens ein Sonderzeichen</li> <li id="pw-rule-symbol" class="pw-rule">Mindestens ein Sonderzeichen</li>
</ul> </ul>
</div> </div>
<div class="input-wrapper"> <div class="input-wrapper">
<div class="input-container"> <div class="input-container">
<span class="input-icon">🔒</span> <span class="input-icon">🔒</span>
<!-- HTML5 Pattern blockiert unsichere Passwörter vor dem Absenden --> <input
<input type="password"
id="password" id="password"
name="password" name="password"
placeholder="Geben Sie ein sicheres Passwort ein" placeholder="Geben Sie ein sicheres Passwort ein"
required required
pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9]).{12,}"> pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9]).{12,}">
<button type="button" id="toggle-pw-btn" class="toggle-pw-btn" onclick="togglePasswordVisibility()" style="background:none; border:none; cursor:pointer; padding-right:10px;">👁️</button>
</div> </div>
</div> </div>
<div class="pw-actions"> <div class="pw-actions" style="margin-top: 8px;">
<button type="button" class="btn-secondary" onclick="generateSecurePassword()">Passwort generieren</button> <button type="button" class="btn-secondary" onclick="generateSecurePassword()">Passwort generieren</button>
</div> </div>
</div> </div>
@@ -120,323 +159,6 @@
</div> </div>
</div> </div>
<style>
:root {
--primary-color: #3498db;
--primary-dark: #2980b9;
--success-color: #2ecc71;
--error-color: #e74c3c;
--text-color: var(--ui-text);
--light-bg: #f9f9f9;
--border-radius: 8px;
--shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
--transition: all 0.3s ease;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: var(--light-bg);
margin: 0;
padding: 0;
color: var(--text-color);
line-height: 1.6;
}
.container {
max-width: 800px;
margin: 2rem auto;
padding: 0 1.5rem;
}
.header-section {
text-align: center;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #eee;
}
.header-section h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
color: var(--primary-color);
}
.subtitle {
font-size: 1.1rem;
color: #777;
margin-top: 0;
}
.content {
background-color: var(--ui-surface);
padding: 2rem;
border-radius: var(--border-radius);
box-shadow: var(--shadow);
margin-bottom: 2rem;
}
.form-card {
max-width: 500px;
margin: 0 auto;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--primary-dark);
}
.input-container {
position: relative;
}
.input-icon {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
color: #aaa;
font-size: 1.2rem;
}
input[type="text"],
input[type="password"],
.form-select {
width: 100%;
padding: 0.8rem 1rem 0.8rem 3rem;
border: 1px solid #ddd;
border-radius: var(--border-radius);
font-size: 1rem;
transition: var(--transition);
box-sizing: border-box;
}
input[type="text"]:focus,
input[type="password"]:focus,
.form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2);
outline: none;
}
.form-select {
padding-left: 1rem;
}
input::placeholder {
color: #aaa;
}
.form-actions {
margin-top: 2rem;
}
.action-button {
background-color: var(--primary-color);
color: white;
border: none;
padding: 0.8rem 1.5rem;
border-radius: 30px;
cursor: pointer;
font-weight: 600;
transition: var(--transition);
width: 100%;
font-size: 1rem;
}
.action-button:hover {
background-color: var(--primary-dark);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.register-button {
background-color: var(--success-color);
}
.register-button:hover {
background-color: #27ae60;
}
.flash-container {
margin-bottom: 1.5rem;
}
.flash {
display: flex;
align-items: center;
padding: 1rem;
margin-bottom: 1rem;
border-radius: var(--border-radius);
animation: fadeIn 0.3s ease-in-out;
}
.flash-icon {
margin-right: 0.8rem;
font-size: 1.2rem;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.3);
}
.flash.success {
background-color: #d4edda;
color: #155724;
border-left: 4px solid var(--success-color);
}
.flash.error {
background-color: #f8d7da;
color: #721c24;
border-left: 4px solid var(--error-color);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 768px) {
.container {
padding: 0 1rem;
margin: 1rem auto;
}
.content {
padding: 1.5rem;
}
.header-section h1 {
font-size: 2rem;
}
}
.password-rules {
margin-bottom: 10px;
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: var(--ui-surface-soft);
}
.password-rules-title {
margin: 0 0 8px;
font-weight: 700;
color: #1f2937;
font-size: 0.92rem;
}
.password-rules ul {
list-style: none;
margin: 0;
padding: 0;
}
.pw-rule {
position: relative;
padding-left: 22px;
margin: 5px 0;
color: #b91c1c;
font-size: 0.9rem;
}
.pw-rule::before {
content: '✗';
position: absolute;
left: 0;
top: 0;
font-weight: 700;
}
.pw-rule.ok {
color: #166534;
}
.pw-rule.ok::before {
content: '✓';
}
.anonymize-hint {
margin-top: 10px;
margin-bottom: 0;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 8px;
padding: 10px 12px;
color: #0c4a6e;
font-size: 0.92rem;
}
.permission-panels {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.permission-panel {
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 10px;
background: var(--ui-surface-soft);
}
.permission-panel h4 {
margin: 0 0 8px;
font-size: 1rem;
color: #1f2937;
}
.permission-check {
display: flex;
align-items: center;
gap: 8px;
margin: 4px 0;
color: #1f2937;
}
/* Neue Styles für die Passwort-Erweiterungen */
.pw-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.btn-secondary {
padding: 8px 12px;
border: 1px solid #d1d5db;
background-color: #f3f4f6;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
transition: background-color 0.2s;
}
.btn-secondary:hover {
background-color: #e5e7eb;
}
.input-wrapper {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.input-wrapper .input-container {
flex-grow: 1;
margin: 0;
}
</style>
<script> <script>
// Hilfsfunktion: Umlaute auflösen und Sonderzeichen entfernen // Hilfsfunktion: Umlaute auflösen und Sonderzeichen entfernen
function cleanNameForUsername(text) { function cleanNameForUsername(text) {
@@ -462,7 +184,7 @@ function generateUsername() {
const firstName = cleanNameForUsername(document.getElementById('name').value); const firstName = cleanNameForUsername(document.getElementById('name').value);
const lastName = cleanNameForUsername(document.getElementById('last-name').value); const lastName = cleanNameForUsername(document.getElementById('last-name').value);
let username = ''; let username = '';
if (firstName && lastName) { if (firstName && lastName) {
username = formatPart(firstName, 3) + formatPart(lastName, 3); username = formatPart(firstName, 3) + formatPart(lastName, 3);
} else if (firstName) { } else if (firstName) {
@@ -470,7 +192,7 @@ function generateUsername() {
} else if (lastName) { } else if (lastName) {
username = formatPart(lastName, 6); username = formatPart(lastName, 6);
} }
const usernameField = document.getElementById('username'); const usernameField = document.getElementById('username');
if (usernameField) { if (usernameField) {
usernameField.value = username || ''; usernameField.value = username || '';
@@ -503,11 +225,12 @@ function generateSecurePassword() {
const pwField = document.getElementById('password'); const pwField = document.getElementById('password');
pwField.value = password; pwField.value = password;
// Automatisch sichtbar machen, damit der User es kopieren kann // Automatisch sichtbar machen
pwField.type = 'text'; pwField.type = 'text';
document.getElementById('toggle-pw-btn').textContent = '🙈'; const toggleBtn = document.getElementById('toggle-pw-btn');
if (toggleBtn) toggleBtn.textContent = '🙈';
updatePasswordRules(); updatePasswordRules();
} }
@@ -517,10 +240,10 @@ function togglePasswordVisibility() {
const toggleBtn = document.getElementById('toggle-pw-btn'); const toggleBtn = document.getElementById('toggle-pw-btn');
if (pwField.type === "password") { if (pwField.type === "password") {
pwField.type = "text"; pwField.type = "text";
toggleBtn.textContent = '🙈'; if (toggleBtn) toggleBtn.textContent = '🙈';
} else { } else {
pwField.type = "password"; pwField.type = "password";
toggleBtn.textContent = '👁️'; if (toggleBtn) toggleBtn.textContent = '👁️';
} }
} }
@@ -530,7 +253,7 @@ function updatePasswordRules() {
if (!passwordInput) return; if (!passwordInput) return;
const value = String(passwordInput.value || ''); const value = String(passwordInput.value || '');
const setRuleState = (id, ok) => { const setRuleState = (id, ok) => {
const node = document.getElementById(id); const node = document.getElementById(id);
if (node) node.classList.toggle('ok', !!ok); if (node) node.classList.toggle('ok', !!ok);
@@ -592,4 +315,409 @@ document.addEventListener('DOMContentLoaded', function () {
} }
}); });
</script> </script>
<style>
:root {
--primary-color: #3498db;
--primary-dark: #2980b9;
--success-color: #2ecc71;
--success-dark: #27ae60;
--csv-color: #059669;
--csv-dark: #047857;
--error-color: #e74c3c;
--text-color: var(--ui-text, #1f2937);
--light-bg: #f9f9f9;
--border-radius: 8px;
--shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
--transition: all 0.3s ease;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: var(--light-bg);
margin: 0;
padding: 0;
color: var(--text-color);
line-height: 1.6;
}
.container {
max-width: 800px;
margin: 2rem auto;
padding: 0 1.5rem;
}
.header-section {
text-align: center;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #eee;
}
.header-section h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
color: var(--primary-color);
}
.subtitle {
font-size: 1.05rem;
color: #6b7280;
margin-top: 0;
}
.content {
background-color: var(--ui-surface, #ffffff);
padding: 2rem;
border-radius: var(--border-radius);
box-shadow: var(--shadow);
margin-bottom: 2rem;
}
/* Karten-Layout für Einzel- & Massenregistrierung */
.form-card {
max-width: 620px;
margin: 0 auto 2.5rem auto;
padding: 1.5rem;
background: var(--ui-surface-soft, #f9fafb);
border: 1px solid #e5e7eb;
border-radius: var(--border-radius);
}
.form-card:last-child {
margin-bottom: 0;
}
.form-card h2 {
font-size: 1.4rem;
margin-top: 0;
margin-bottom: 0.5rem;
color: #111827;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.4rem;
font-weight: 600;
font-size: 0.95rem;
color: #374151;
}
.input-container {
position: relative;
width: 100%;
}
.input-icon {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
color: #9ca3af;
font-size: 1.1rem;
pointer-events: none;
z-index: 1;
}
/* Eingabefelder & Selects */
input[type="text"],
input[type="password"],
input[type="file"],
.form-select {
width: 100%;
padding: 0.75rem 1rem 0.75rem 2.8rem;
border: 1px solid #d1d5db;
border-radius: var(--border-radius);
font-size: 0.95rem;
background-color: #ffffff;
transition: var(--transition);
box-sizing: border-box;
}
input[type="text"]:focus,
input[type="password"]:focus,
input[type="file"]:focus,
.form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2);
outline: none;
}
.form-select {
padding-left: 1rem;
cursor: pointer;
}
/* Stylings für den CSV-Datei-Upload */
input[type="file"] {
padding-left: 2.8rem;
cursor: pointer;
}
input[type="file"]::file-selector-button {
padding: 0.35rem 0.75rem;
border-radius: 4px;
border: 1px solid #d1d5db;
background: #f3f4f6;
color: #374151;
cursor: pointer;
margin-right: 12px;
font-weight: 500;
transition: var(--transition);
}
input[type="file"]::file-selector-button:hover {
background: #e5e7eb;
}
input::placeholder {
color: #9ca3af;
}
code {
background: #e0f2fe;
color: #0369a1;
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
font-size: 0.9em;
}
/* Buttons */
.form-actions {
margin-top: 1.5rem;
}
.action-button {
background-color: var(--primary-color);
color: white;
border: none;
padding: 0.8rem 1.5rem;
border-radius: var(--border-radius);
cursor: pointer;
font-weight: 600;
transition: var(--transition);
width: 100%;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.action-button:hover {
background-color: var(--primary-dark);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.register-button {
background-color: var(--success-color);
}
.register-button:hover {
background-color: var(--success-dark);
}
/* Toggle-Button für Passwort-Auge */
.input-wrapper {
position: relative;
width: 100%;
}
.toggle-pw-btn {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
font-size: 1.1rem;
padding: 4px;
z-index: 2;
opacity: 0.7;
transition: opacity 0.2s;
}
.toggle-pw-btn:hover {
opacity: 1;
}
/* Passwort-Regeln Box */
.password-rules {
margin-bottom: 10px;
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #ffffff;
}
.password-rules-title {
margin: 0 0 6px;
font-weight: 700;
color: #1f2937;
font-size: 0.88rem;
}
.password-rules ul {
list-style: none;
margin: 0;
padding: 0;
}
.pw-rule {
position: relative;
padding-left: 20px;
margin: 4px 0;
color: #dc2626;
font-size: 0.85rem;
}
.pw-rule::before {
content: '✗';
position: absolute;
left: 0;
top: 0;
font-weight: 700;
}
.pw-rule.ok {
color: #166534;
}
.pw-rule.ok::before {
content: '✓';
}
.pw-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.btn-secondary {
padding: 6px 12px;
border: 1px solid #d1d5db;
background-color: #ffffff;
color: #374151;
border-radius: 6px;
cursor: pointer;
font-size: 0.88rem;
font-weight: 500;
transition: var(--transition);
}
.btn-secondary:hover {
background-color: #f3f4f6;
border-color: #9ca3af;
}
/* Hinweise & Rechte-Panels */
.anonymize-hint {
margin-top: 8px;
margin-bottom: 0;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 6px;
padding: 8px 12px;
color: #0369a1;
font-size: 0.88rem;
}
.permission-panels {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.permission-panel {
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 12px;
background: #ffffff;
}
.permission-panel h4 {
margin: 0 0 8px;
font-size: 0.95rem;
color: #1f2937;
}
.permission-check {
display: flex;
align-items: center;
gap: 8px;
margin: 6px 0;
color: #374151;
font-size: 0.9rem;
cursor: pointer;
}
/* Flash Nachrichten */
.flash-container {
margin-bottom: 1.5rem;
}
.flash {
display: flex;
align-items: center;
padding: 0.9rem 1rem;
margin-bottom: 1rem;
border-radius: var(--border-radius);
animation: fadeIn 0.3s ease-in-out;
}
.flash-icon {
margin-right: 0.8rem;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.4);
}
.flash.success {
background-color: #d1fae5;
color: #065f46;
border-left: 4px solid var(--csv-color);
}
.flash.error {
background-color: #fee2e2;
color: #991b1b;
border-left: 4px solid var(--error-color);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 768px) {
.container {
padding: 0 0.75rem;
margin: 1rem auto;
}
.content {
padding: 1rem;
}
.form-card {
padding: 1rem;
}
.header-section h1 {
font-size: 1.8rem;
}
}
</style>
{% endblock %} {% endblock %}
@@ -11,7 +11,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Schülerausweis - Barcode PDF Download</title> <title>Bibliotheksausweis - Barcode PDF Download</title>
<style> <style>
* { * {
margin: 0; margin: 0;
@@ -123,7 +123,7 @@
<body> <body>
<div class="container"> <div class="container">
<div class="icon">📇</div> <div class="icon">📇</div>
<h1>Schülerausweis-Download</h1> <h1>Bibliotheksausweis-Download</h1>
<p>Generieren Sie eine PDF mit Barcodes aller Bibliotheksausweise zum direkten Drucken</p> <p>Generieren Sie eine PDF mit Barcodes aller Bibliotheksausweise zum direkten Drucken</p>
<div class="info-box"> <div class="info-box">
+1 -1
View File
@@ -249,7 +249,7 @@
<!-- Add/Edit Form --> <!-- Add/Edit Form -->
<div class="student-card-form"> <div class="student-card-form">
<h2>{% if edit_mode %}Ausweis bearbeiten{% else %}Neuer Schülerausweis{% endif %}</h2> <h2>{% if edit_mode %}Ausweis bearbeiten{% else %}Neuer Bibliotheksausweis{% endif %}</h2>
<form method="POST" action="{{ url_for('student_cards_admin') }}"> <form method="POST" action="{{ url_for('student_cards_admin') }}">
{% if edit_mode %} {% if edit_mode %}
+3 -1
View File
@@ -954,8 +954,10 @@
<label for="anschaffungskosten">Anschaffungskosten (€)</label> <label for="anschaffungskosten">Anschaffungskosten (€)</label>
<input id="anschaffungskosten" name="anschaffungskosten"> <input id="anschaffungskosten" name="anschaffungskosten">
</div> </div>
<!-- Image upload (hidden for library mode) --> <!-- Image upload (hidden for library mode) -->
<div class="form-group" {% if show_library_features %}style="display:none;"{% endif %}> <div class="form-group">
<label for="images">Bilder/Videos:</label> <label for="images">Bilder/Videos:</label>
<input type="file" id="images" name="images" accept=".jpg, .jpeg, .png, .gif, .mp4, .mov, .avi, .mkv, .webm, .flv, .m4v, .3gp" multiple> <input type="file" id="images" name="images" accept=".jpg, .jpeg, .png, .gif, .mp4, .mov, .avi, .mkv, .webm, .flv, .m4v, .3gp" multiple>
<div class="allowed-formats">Erlaubte Formate: JPG, JPEG, PNG, GIF, MP4, MOV, AVI, MKV, WEBM, FLV, M4V, 3GP</div> <div class="allowed-formats">Erlaubte Formate: JPG, JPEG, PNG, GIF, MP4, MOV, AVI, MKV, WEBM, FLV, M4V, 3GP</div>