Compare commits

...

20 Commits

Author SHA1 Message Date
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 931 additions and 2429 deletions
+335 -228
View File
@@ -27,6 +27,10 @@ import os
import sys
from bs4 import BeautifulSoup
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.
_CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -449,7 +453,7 @@ PERMISSION_ACTION_ENDPOINTS = {
'admin_reset_user_password': 'can_manage_users',
'admin_update_user_permissions': 'can_manage_users',
'admin_anonymize_names': 'can_manage_users',
'home_admin': 'can_manage_settings',
'home_admin': 'can_borrow',
'upload_admin': 'can_insert',
'library_admin': 'can_insert',
'admin_borrowings': 'can_manage_settings',
@@ -462,15 +466,36 @@ PERMISSION_ACTION_ENDPOINTS = {
}
ALLOWED_COVER_DOMAINS = {
# --- Google / Open APIs ---
"books.google.com",
"covers.openlibrary.org",
"images-na.ssl-images-amazon.com",
"m.media-amazon.com",
"www.isbn.de",
"www.googleapis.com",
# --- Open Library / Internet Archive ---
"covers.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",
"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"]
@@ -786,6 +811,8 @@ def _page_access_allowed(permissions, endpoint):
if not permissions or not endpoint:
return True
page_permissions = permissions.get('pages', {})
if endpoint == "home_admin":
return 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):
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:
continue
if candidate == 'home_admin' and is_admin_user and not admin_home_allowed:
continue
if _page_access_allowed(permissions, candidate):
return candidate
return 'logout'
@@ -4347,7 +4370,7 @@ def student_card_barcode_download():
# "SCHÜLERAUSWEIS" text in header
c.setFont("Helvetica-Bold", 9)
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
c.setFillColor(text_dark)
@@ -4523,7 +4546,7 @@ def student_card_single_barcode_download(card_id):
# "SCHÜLERAUSWEIS" text in header
c.setFont("Helvetica-Bold", 11)
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
c.setFillColor(text_dark)
@@ -5530,10 +5553,10 @@ def upload_item():
app.logger.info(f"Starting image upload session {upload_session_id} - Files: {len(images)}, User: {encrypt_text(username)}")
for index, image in enumerate(images):
if upload_mode == 'library':
app.logger.info(f"[Upload {upload_session_id}] Skipping manual upload (Library Mode)")
skipped_count += 1
continue
#if upload_mode == 'library':
# app.logger.info(f"[Upload {upload_session_id}] Skipping manual upload (Library Mode)")
# skipped_count += 1
# continue
if not image or not image.filename:
skipped_count += 1
@@ -6212,174 +6235,6 @@ def bulk_delete_items():
if client:
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'])
def item_edit(id):
if 'username' not in session:
@@ -7861,35 +7716,282 @@ def register():
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'])
def user_del():
"""
User deletion interface.
Displays a list of users that can be deleted by an administrator.
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:
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'))
# Abruf aller User (falls get_all_users tenant_id unterstützt, kann diese hier übergeben werden)
all_users = us.get_all_users()
users_list = []
for user in all_users:
username = None
for field in ['Username']:
if field in user:
username = user[field]
break
if username and username != session['username']:
username = user.get('Username')
if username and username != session.get('username'):
try:
permissions_payload = us.get_effective_permissions(username)
except Exception:
permissions_payload = us.build_default_permission_payload('standard_user')
try:
name = us.get_name(username)
last_name = us.get_last_name(username)
@@ -7901,10 +8003,11 @@ def user_del():
fullname = last_name
else:
fullname = None
except:
except Exception:
name = ""
last_name = ""
fullname = None
users_list.append({
'username': decrypt_text(username),
'admin': user.get('Admin', False),
@@ -7915,7 +8018,7 @@ def user_del():
'action_permissions': permissions_payload.get('actions', {}),
'page_permissions': permissions_payload.get('pages', {}),
})
return render_template(
'user_del.html',
users=users_list,
@@ -7928,68 +8031,72 @@ def user_del():
def delete_user():
"""
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.
Returns:
flask.Response: Redirect to the user deletion interface with status
"""
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'))
username = request.form.get('username')
if not username:
flash('Kein Benutzer ausgewählt', 'error')
return redirect(url_for('user_del'))
# Prevent self-deletion
if username == session['username']:
if username == session.get('username'):
flash('Sie können Ihr eigenes Konto nicht löschen', 'error')
return redirect(url_for('user_del'))
# Reset this user's borrowings and free items before deleting the user
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
tenant_id = session.get('tenant_id')
db = us._get_tenant_db(client, tenant_id)
ausleihungen = db['ausleihungen']
items_col = db['items']
users_col = db['users'] # Direkter Zugriff auf die User-Collection
now = datetime.datetime.now()
# Complete all active borrowings of this user
# 1. Aktive Ausleihen abschließen
ausleihungen.update_many(
{'User': username, 'Status': 'active'},
{'$set': {'Status': 'completed', 'End': now, 'LastUpdated': now}}
)
# Cancel all planned borrowings of this user
# 2. Geplante Ausleihen stornieren
ausleihungen.update_many(
{'User': username, 'Status': 'planned'},
{'$set': {'Status': 'cancelled', 'LastUpdated': now}}
)
# Free all items currently associated with this user
# 3. Inventar wieder verfügbar machen
items_col.update_many(
{'User': username},
{'$set': {'Verfuegbar': True, 'LastUpdated': now}, '$unset': {'User': ""}}
)
client.close()
except Exception as e:
app.logger.error(f"Error resetting borrowings for user {encrypt_text(username)}: {e}")
flash(f'Warnung: Ausleihungen/Reservierungen für {username} konnten nicht vollständig zurückgesetzt werden', 'warning')
# 4. Den Benutzer direkt in der überprüften DB-Verbindung löschen
# Achte auf die exakte Großschreibung 'Username' (so wie in add_user definiert)
delete_result = users_col.delete_one({'Username': username})
# 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:
app.logger.error(f"Error deleting user {encrypt_text(username)}: {e}")
flash('Fehler beim Löschen des Benutzers', 'error')
app.logger.error(f"Error resetting borrowings or deleting user {username}: {e}")
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'))
@app.route('/admin/borrowings')
def admin_borrowings():
"""
@@ -9955,8 +10062,8 @@ def download_book_cover():
return jsonify({"error": "Only public HTTPS URLs are allowed"}), 400
# 2. SSRF Protection: Strict Allowlist Check
if parsed_url.netloc not in ALLOWED_COVER_DOMAINS:
return jsonify({"error": "Target host is not an allowed book cover provider"}), 403
# if parsed_url.netloc not in ALLOWED_COVER_DOMAINS:
# return jsonify({"error": "Target host is not an allowed book cover provider"}), 403
# Download the image (allow_redirects=False prevents redirecting to internal IPs)
response = requests.get(image_url, stream=True, timeout=10, allow_redirects=False)
+26 -27
View File
@@ -211,6 +211,7 @@ PERMISSION_PRESETS = {
},
'pages': {
'home': True,
'home_admin': True,
'tutorial_page': True,
'my_borrowed_items': 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)
db = _get_tenant_db(client)
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:
result = users.update_one({'username': dp.encrypt_text(username)}, {'$set': update_data})
result = users.update_one({'username': username}, {'$set': update_data})
client.close()
return result.matched_count > 0
@@ -384,7 +385,7 @@ def get_favorites(username):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
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()
if not user:
return []
@@ -399,7 +400,7 @@ def add_favorite(username, item_id):
db = _get_tenant_db(client)
users = db['users']
users.update_one(
{'$or': [{'Username': dp.encrypt_text(username)}, {'username': dp.encrypt_text(username)}]},
{'$or': [{'Username': username}, {'username': username}]},
{'$addToSet': {'favorites': ObjectId(item_id)}}
)
client.close()
@@ -414,7 +415,7 @@ def remove_favorite(username, item_id):
db = _get_tenant_db(client)
users = db['users']
users.update_one(
{'$or': [{'Username': dp.encrypt_text(username)}, {'username': dp.encrypt_text(username)}]},
{'$or': [{'Username': username}, {'username': username}]},
{'$pull': {'favorites': ObjectId(item_id)}}
)
client.close()
@@ -496,7 +497,7 @@ def check_nm_pwd(username, password):
db = client[db_name]
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)
if user_record is None:
@@ -507,8 +508,6 @@ def check_nm_pwd(username, password):
default_users = client[cfg.MONGODB_DB]['users']
user_record_fallback = default_users.find_one(
{'$or': [
{'Username': dp.encrypt_text(username)},
{'username': dp.encrypt_text(username)},
{'Username': username},
{'username': username},
]}
@@ -711,9 +710,9 @@ def make_admin(username):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
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:
result = users.update_one({'username': dp.encrypt_text(username)}, {'$set': {'Admin': True}})
result = users.update_one({'username': username}, {'$set': {'Admin': True}})
client.close()
return result.matched_count > 0
@@ -723,9 +722,9 @@ def remove_admin(username):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
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:
result = users.update_one({'username': dp.encrypt_text(username)}, {'$set': {'Admin': False}})
result = users.update_one({'username': username}, {'$set': {'Admin': False}})
client.close()
return result.matched_count > 0
@@ -737,7 +736,7 @@ def get_user(username):
def find_in_db(database_name):
db = client[database_name]
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()
if tenant_db:
@@ -773,12 +772,12 @@ def update_active_ausleihung(username, id_item, ausleihung):
users = db['users']
result = users.update_one(
{'Username': dp.encrypt_text(username)},
{'Username': username},
{'$set': {'active_ausleihung': {'Item': id_item, 'Ausleihung': ausleihung}}}
)
if result.matched_count == 0:
users.update_one(
{'username': dp.encrypt_text(username)},
{'username': username},
{'$set': {'active_ausleihung': {'Item': id_item, 'Ausleihung': ausleihung}}}
)
client.close()
@@ -791,7 +790,7 @@ def get_active_ausleihung(username):
db = _get_tenant_db(client)
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()
if not user:
@@ -806,7 +805,7 @@ def has_active_borrowing(username):
db = _get_tenant_db(client)
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()
if not user:
@@ -823,9 +822,9 @@ def delete_user(username):
db = _get_tenant_db(client)
users = db['users']
result = users.delete_one({'username': dp.encrypt_text(username)})
result = users.delete_one({'username': username})
if result.deleted_count == 0:
result = users.delete_one({'Username': dp.encrypt_text(username)})
result = users.delete_one({'Username': username})
client.close()
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}}
result = users.update_one({'username': dp.encrypt_text(username)}, update_data)
result = users.update_one({'username': username}, update_data)
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()
return result.modified_count > 0
@@ -856,7 +855,7 @@ def get_name(username):
db = _get_tenant_db(client)
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()
if not user or not user.get("name"):
@@ -871,7 +870,7 @@ def get_last_name(username):
db = _get_tenant_db(client)
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()
if not user or not user.get("last_name"):
@@ -906,12 +905,12 @@ def update_password(username, new_password):
hashed_password = hashing(new_password)
result = users.update_one(
{'Username': dp.encrypt_text(username)},
{'Username': username},
{'$set': {'Password': hashed_password}}
)
if result.matched_count == 0:
result = users.update_one(
{'username': dp.encrypt_text(username)},
{'username': username},
{'$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 ''
result = users.update_one(
{'Username': dp.encrypt_text(username)},
{'Username': username},
{'$set': {'name': safe_name, 'last_name': safe_last_name}}
)
if result.matched_count == 0:
result = users.update_one(
{'username': dp.encrypt_text(username)},
{'username': username},
{'$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 %}
+101 -54
View File
@@ -52,9 +52,10 @@
/* The Scrollable Content Area */
#detailContent {
overflow-y: auto; /* Adds scrollbar only if needed */
padding-right: 10px; /* Prevents text from rubbing against the scrollbar */
overflow-y: auto;
padding-right: 10px;
}
/* Library table-only view styles */
.library-table-container {
max-width: 1400px;
@@ -276,6 +277,7 @@
border-bottom: 1px solid #eee;
color: #555;
font-size: 0.95em;
vertical-align: middle;
}
.library-items-table tbody tr:hover {
@@ -500,14 +502,14 @@
<!-- Search and Filter Toggle -->
<div class="library-search-bar">
<input
type="text"
id="librarySearch"
class="library-search-input"
<input
type="text"
id="librarySearch"
class="library-search-input"
placeholder="Nach Titel, ISBN suchen..."
>
<button
id="filterToggleBtn"
<button
id="filterToggleBtn"
class="library-filter-toggle-btn"
aria-label="Filter öffnen/schließen"
>
@@ -539,7 +541,7 @@
<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.
Hinweis: Im Schnellmodus zuerst den Bibliotheksausweis scannen, danach den Buch-/Mediencode.
</div>
<div id="scanReaderWrap" class="library-scan-reader-wrap" style="display: none;">
<div id="libraryQrReader" class="library-scan-reader">
@@ -598,12 +600,12 @@
<table class="library-items-table">
<thead>
<tr>
<th style="width: 24%;">Titel</th>
<th style="width: 32%;">Titel</th>
<th style="width: 12%;">ISBN/Code</th>
<th style="width: 8%;">Typ</th>
<th style="width: 8%;">Anzahl</th>
<th style="width: 12%;">Status</th>
<th style="width: 22%;">Aktionen</th>
<th style="width: 14%;">Status</th>
<th style="width: 26%;">Aktionen</th>
</tr>
</thead>
<tbody id="itemsTableBody">
@@ -656,11 +658,11 @@
const RENDER_BATCH_COUNT = 120;
let renderedCount = INITIAL_RENDER_COUNT;
let filterPanelOpen = false;
// Scanner Related State Variables
let scannerInstance = null;
let scannerRunning = false;
let activeScannerCallback = null;
let scannerRunning = false;
let activeScannerCallback = null;
let activeStudentCardId = '';
let lastScanValue = '';
let lastScanAt = 0;
@@ -674,9 +676,14 @@
seriesGroupId: '',
groupMembers: []
};
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
// =========================================================================
@@ -705,7 +712,7 @@
}
} catch (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 {
pagingState.loading = false;
}
@@ -797,6 +804,7 @@
const statusText = statusKey === 'damaged' ? 'Defekt/Zerstört' : (statusKey === 'borrowed' ? 'Ausgeliehen' : 'Verfügbar');
const actionLabel = statusKey === 'available' ? 'Ausleihen' : (statusKey === 'borrowed' ? 'Reservieren' : 'Nicht ausleihbar');
const actionDisabled = statusKey === 'damaged' ? 'disabled' : '';
return `
<tr>
<td class="table-title">${escapeHtml(item.Name || 'Untitled')}</td>
@@ -877,33 +885,33 @@
function startScanner(targetCallback) {
const readerWrap = document.getElementById('scanReaderWrap');
const toggleBtn = document.getElementById('toggleScannerBtn');
activeScannerCallback = targetCallback;
if (readerWrap) readerWrap.style.display = 'block';
setScanStatus('Initializing camera...', 'warn');
Quagga.init({
inputStream: {
name: "Live",
type: "LiveStream",
target: document.querySelector('#library-scanner-container'),
target: document.querySelector('#library-scanner-container'),
constraints: {
width: 640,
height: 480,
facingMode: "environment"
facingMode: "environment"
},
},
decoder: {
readers: [
"code_128_reader",
"ean_reader",
"code_39_reader",
"upc_reader",
"codabar_reader",
"code_128_reader",
"ean_reader",
"code_39_reader",
"upc_reader",
"codabar_reader",
"i2of5_reader"
]
}
}
}, function(err) {
if (err) {
console.error('Scanner start failed:', err);
@@ -912,41 +920,41 @@
setScanStatus(`Scanner konnte nicht gestartet werden${detail}`, 'error');
return;
}
Quagga.start();
scannerRunning = true;
if (!targetCallback && toggleBtn) {
toggleBtn.textContent = 'Scanner stoppen';
}
setScanStatus('Scanner aktiv. Jetzt Code scannen.', 'warn');
});
}
function stopScanner() {
if (!scannerRunning) return;
const readerWrap = document.getElementById('scanReaderWrap');
const toggleBtn = document.getElementById('toggleScannerBtn');
Quagga.stop();
scannerRunning = false;
activeScannerCallback = null;
activeScannerCallback = null;
if (readerWrap) readerWrap.style.display = 'none';
if (toggleBtn) toggleBtn.textContent = 'Scanner starten';
setScanStatus('Scanner gestoppt.', 'warn');
}
Quagga.onDetected(function(data) {
if (!data || !data.codeResult || !data.codeResult.code) return;
const barcode = String(data.codeResult.code || '').trim();
console.log("Barcode detected:", barcode);
const currentCallback = activeScannerCallback;
stopScanner();
const returnOnly = (document.getElementById('returnOnlyToggle') || {}).checked;
if (returnOnly) {
// direct return flow
@@ -1008,21 +1016,21 @@
function handleScanSuccess(decodedText) {
const scannedCode = normalizeScannedCode(decodedText);
if (!scannedCode) return;
const now = Date.now();
if (scannedCode === lastScanValue && (now - lastScanAt) < 1500) {
return;
}
lastScanValue = scannedCode;
lastScanAt = now;
const mode = (document.getElementById('scanModeSelect') || {}).value || 'card_only';
if (mode === 'card_only') {
setActiveStudentCard(scannedCode);
setScanStatus(`Ausweis gesetzt: ${activeStudentCardId}`, 'ok');
return;
}
processQuickToggleScan(scannedCode);
}
@@ -1140,9 +1148,9 @@
}
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) {
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;
}
@@ -1244,30 +1252,69 @@
return div.innerHTML;
}
// Opens the modal and fetches the data
function showItemDetail(itemId) {
const detailContent = document.getElementById('detailContent');
const detailModal = document.getElementById('detailModal');
// 1. Show the loading state immediately
detailContent.innerHTML = '<p>Loading details...</p>';
detailContent.innerHTML = '<p>Lade Details...</p>';
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}`)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.text();
})
.then(html => {
// 3. Clean the HTML and display it
detailContent.innerHTML = DOMPurify.sanitize(html);
detailContent.innerHTML = mediaHtml + DOMPurify.sanitize(html);
})
.catch(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 +1361,7 @@
if (modeSelect) {
modeSelect.addEventListener('change', () => {
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') {
setScanStatus('Nur Ausweis-Modus aktiv.', 'warn');
}
+463 -335
View File
@@ -6,7 +6,7 @@
<div class="container">
<div class="header-section">
<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 class="flash-container">
@@ -23,7 +23,43 @@
</div>
<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">
<h2>Einzelnen Benutzer registrieren</h2>
<form method="POST" action="{{ url_for('register') }}">
<div class="form-group">
@@ -32,11 +68,13 @@
<span class="input-icon">👤</span>
<input type="text" id="name" name="name" placeholder="Geben Sie den Vornamen ein" required onchange="generateUsername()" oninput="generateUsername()">
</div>
<label for="last-name">Nachname</label>
<div class="input-container">
<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()">
</div>
<label for="username">Benutzername <span style="color: #9ca3af;">(Vorschau - wird serverseitig finalisiert)</span></label>
<div class="input-container">
<span class="input-icon">👤</span>
@@ -44,7 +82,7 @@
</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>
</div>
<div class="form-group">
<label for="password">Passwort</label>
<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>
</ul>
</div>
<div class="input-wrapper">
<div class="input-container">
<span class="input-icon">🔒</span>
<!-- HTML5 Pattern blockiert unsichere Passwörter vor dem Absenden -->
<input
id="password"
name="password"
placeholder="Geben Sie ein sicheres Passwort ein"
<input
type="password"
id="password"
name="password"
placeholder="Geben Sie ein sicheres Passwort ein"
required
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 class="pw-actions">
<div class="pw-actions" style="margin-top: 8px;">
<button type="button" class="btn-secondary" onclick="generateSecurePassword()">Passwort generieren</button>
</div>
</div>
@@ -120,323 +159,6 @@
</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>
// Hilfsfunktion: Umlaute auflösen und Sonderzeichen entfernen
function cleanNameForUsername(text) {
@@ -462,7 +184,7 @@ function generateUsername() {
const firstName = cleanNameForUsername(document.getElementById('name').value);
const lastName = cleanNameForUsername(document.getElementById('last-name').value);
let username = '';
if (firstName && lastName) {
username = formatPart(firstName, 3) + formatPart(lastName, 3);
} else if (firstName) {
@@ -470,7 +192,7 @@ function generateUsername() {
} else if (lastName) {
username = formatPart(lastName, 6);
}
const usernameField = document.getElementById('username');
if (usernameField) {
usernameField.value = username || '';
@@ -503,11 +225,12 @@ function generateSecurePassword() {
const pwField = document.getElementById('password');
pwField.value = password;
// Automatisch sichtbar machen, damit der User es kopieren kann
// Automatisch sichtbar machen
pwField.type = 'text';
document.getElementById('toggle-pw-btn').textContent = '🙈';
const toggleBtn = document.getElementById('toggle-pw-btn');
if (toggleBtn) toggleBtn.textContent = '🙈';
updatePasswordRules();
}
@@ -517,10 +240,10 @@ function togglePasswordVisibility() {
const toggleBtn = document.getElementById('toggle-pw-btn');
if (pwField.type === "password") {
pwField.type = "text";
toggleBtn.textContent = '🙈';
if (toggleBtn) toggleBtn.textContent = '🙈';
} else {
pwField.type = "password";
toggleBtn.textContent = '👁️';
if (toggleBtn) toggleBtn.textContent = '👁️';
}
}
@@ -530,7 +253,7 @@ function updatePasswordRules() {
if (!passwordInput) return;
const value = String(passwordInput.value || '');
const setRuleState = (id, ok) => {
const node = document.getElementById(id);
if (node) node.classList.toggle('ok', !!ok);
@@ -592,4 +315,409 @@ document.addEventListener('DOMContentLoaded', function () {
}
});
</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 %}
@@ -11,7 +11,7 @@
<head>
<meta charset="UTF-8">
<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>
* {
margin: 0;
@@ -123,7 +123,7 @@
<body>
<div class="container">
<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>
<div class="info-box">
+1 -1
View File
@@ -249,7 +249,7 @@
<!-- Add/Edit 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') }}">
{% if edit_mode %}
+3 -1
View File
@@ -954,8 +954,10 @@
<label for="anschaffungskosten">Anschaffungskosten (€)</label>
<input id="anschaffungskosten" name="anschaffungskosten">
</div>
<!-- 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>
<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>