Compare commits

...

22 Commits

Author SHA1 Message Date
Aiirondev_dev b1ca358d41 Cleanup of the front end and backend of the upload function to only allow images and no videos because of space and memory reasons as well as hardening the uploading process.
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-15 01:38:35 +02:00
Aiirondev_dev 47cba5865d Removal of dupolicate function.
Release Inventarsystem / release-docker (push) Successful in 4m55s
2026-08-15 01:33:38 +02:00
Aiirondev_dev 4a776872e0 Improvement of the style for the Library Module
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-15 00:08:13 +02:00
Aiirondev_dev 55a74654db Addition of a missing ','
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-14 23:18:29 +02:00
Aiirondev_dev 7473334714 Changes to the Inventarsystem Workflow to Reflekt the new workflow and have an according set of buttons. So this removed all metioning of the duplication function.
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-14 23:01:01 +02:00
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
9 changed files with 494 additions and 2975 deletions
+79 -527
View File
@@ -453,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',
@@ -466,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"]
@@ -790,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))
@@ -802,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'
@@ -2640,6 +2659,12 @@ def upload_student_cards_excel():
"""Bulk import student cards from 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-----------------------------------------------------------------------------"""
@@ -3902,7 +3927,6 @@ def upload_admin():
"""
Upload page route for inventory items.
Accessible to users with insert permission.
Supports duplication by passing duplicate_from parameter.
Returns:
flask.Response: Rendered template or redirect
@@ -3915,54 +3939,9 @@ def upload_admin():
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')
return redirect(url_for('login'))
# Check if this is a duplication request
duplicate_from = request.args.get('duplicate_from')
duplicate_flag = request.args.get('duplicate') # Check for sessionStorage-based duplication
duplicate_data = None
# Handle the old method (duplicate_from parameter with item ID)
if duplicate_from:
try:
original_item = it.get_item(duplicate_from)
if original_item:
duplicate_data = {
'name': original_item.get('Name', ''),
'description': original_item.get('Beschreibung', ''),
'location': original_item.get('Ort', ''),
'room': original_item.get('Raum', ''),
'category': original_item.get('Kategorie', ''),
'year': original_item.get('Anschaffungsjahr', ''),
'cost': original_item.get('Anschaffungskosten', ''),
'filter1': original_item.get('Filter1', ''),
'filter2': original_item.get('Filter2', ''),
'filter3': original_item.get('Filter3', ''),
'images': original_item.get('Images', []),
'original_id': duplicate_from
}
# Copy all filter fields (Filter1_1 through Filter3_5)
for i in range(1, 4): # Filter1, Filter2, Filter3
for j in range(1, 6): # _1 through _5
filter_key = f'Filter{i}_{j}'
if filter_key in original_item:
duplicate_data[f'filter{i}_{j}'] = original_item[filter_key]
flash('Element wird dupliziert. Bitte überprüfen Sie die Daten und passen Sie sie bei Bedarf an.', 'info')
else:
flash('Ursprungs-Element für Duplizierung nicht gefunden.', 'error')
except Exception as e:
app.logger.warning(f"Error loading item for duplication: {e}")
flash('Fehler beim Laden der Duplizierungsdaten.', 'error')
# Handle the new method (sessionStorage-based duplication)
elif duplicate_flag == 'true':
# No server-side processing needed - JavaScript will handle sessionStorage data
# Just indicate that duplication mode is active
flash('Element wird dupliziert. Die Daten werden aus dem Session-Speicher geladen.', 'info')
return render_template(
'upload_admin.html',
username=session['username'],
duplicate_data=duplicate_data,
library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
show_library_features=False,
@@ -3989,46 +3968,9 @@ def library_admin():
flash('Bibliotheks-Modul ist deaktiviert.', 'error')
return redirect(url_for('home_admin'))
duplicate_from = request.args.get('duplicate_from')
duplicate_flag = request.args.get('duplicate')
duplicate_data = None
if duplicate_from:
try:
original_item = it.get_item(duplicate_from)
if original_item:
duplicate_data = {
'name': original_item.get('Name', ''),
'description': original_item.get('Beschreibung', ''),
'location': original_item.get('Ort', ''),
'room': original_item.get('Raum', ''),
'category': original_item.get('Kategorie', ''),
'year': original_item.get('Anschaffungsjahr', ''),
'cost': original_item.get('Anschaffungskosten', ''),
'filter1': original_item.get('Filter1', ''),
'filter2': original_item.get('Filter2', ''),
'filter3': original_item.get('Filter3', ''),
'images': original_item.get('Images', []),
'original_id': duplicate_from
}
for i in range(1, 4):
for j in range(1, 6):
filter_key = f'Filter{i}_{j}'
if filter_key in original_item:
duplicate_data[f'filter{i}_{j}'] = original_item[filter_key]
flash('Buch wird dupliziert. Bitte überprüfen Sie die Daten und passen Sie sie bei Bedarf an.', 'info')
else:
flash('Ursprungs-Element für Duplizierung nicht gefunden.', 'error')
except Exception as e:
app.logger.warning(f"Error loading item for duplication: {e}")
flash('Fehler beim Laden der Duplizierungsdaten.', 'error')
elif duplicate_flag == 'true':
flash('Buch wird dupliziert. Die Daten werden aus dem Session-Speicher geladen.', 'info')
return render_template(
'upload_admin.html',
username=session['username'],
duplicate_data=duplicate_data,
library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
show_library_features=True,
@@ -4351,7 +4293,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)
@@ -4527,7 +4469,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)
@@ -5297,15 +5239,15 @@ def debug_favorites():
def upload_item():
"""
Route for adding new items to the inventory.
Handles file uploads and creates QR codes.
Handles image file uploads and creates QR codes.
Enhanced for mobile browser compatibility.
Returns:
flask.Response: Redirect to admin homepage
"""
if 'username' not in session:
return jsonify({'success': False, 'message': 'Nicht angemeldet'}), 401
item_is_library = False
# Check if user may insert items
@@ -5323,20 +5265,20 @@ def upload_item():
# Detect if request is from mobile device
is_mobile = 'Mobile' in request.headers.get('User-Agent', '')
# Log mobile request for debugging
if is_mobile:
app.logger.info(f"Mobile upload from {request.headers.get('User-Agent', 'unknown')} by {encrypt_text(username)}")
app.logger.info(
f"Mobile upload from {request.headers.get('User-Agent', 'unknown')} by {encrypt_text(username)}")
try:
# Strip whitespace from all text fields
name = sanitize_form_value(request.form['name'])
ort = sanitize_form_value(request.form['ort'])
beschreibung = sanitize_form_value(request.form['beschreibung'])
# Check both possible image field names
images = request.files.getlist('images') or request.files.getlist('new_images')
filter_upload = sanitize_form_value(request.form.getlist('filter'))
filter_upload2 = sanitize_form_value(request.form.getlist('filter2'))
filter_upload3 = sanitize_form_value(request.form.getlist('filter3'))
@@ -5349,7 +5291,7 @@ def upload_item():
item_count_raw = sanitize_form_value(request.form.get('item_count', '1'))
item_type_input = sanitize_form_value(request.form.get('item_type_input', ''))
library_category = sanitize_form_value(request.form.get('library_category', ''))
if library_category != '':
item_is_library = True
@@ -5359,46 +5301,30 @@ def upload_item():
item_count = 1
item_count = max(1, min(item_count, 100))
# Optional list of per-item codes (one code per line)
# Optional list of per-item codes
individual_codes = []
if individual_codes_raw:
individual_codes = [c.strip() for c in str(individual_codes_raw).replace(',', '\n').splitlines() if c.strip()]
individual_codes = [c.strip() for c in str(individual_codes_raw).replace(',', '\n').splitlines() if
c.strip()]
# Check if this is a duplication
is_duplicating = request.form.get('is_duplicating') == 'true'
# Get duplicate_images if duplicating
duplicate_images = request.form.getlist('duplicate_images') if is_duplicating else []
# Make sure duplicate_images is always a list, even if there's only one
if is_duplicating and duplicate_images and not isinstance(duplicate_images, list):
duplicate_images = [duplicate_images]
# Log details about each image
if is_duplicating and duplicate_images:
for i, img in enumerate(duplicate_images):
print(f"DEBUG: Duplicate image {i+1}/{len(duplicate_images)}: {img}")
# Get book cover image if downloaded
book_cover_image = request.form.get('book_cover_image')
# Special handling for mobile browsers that might send data differently
# Special handling for mobile browsers
if is_mobile and 'mobile_data' in request.form:
try:
mobile_data = json.loads(request.form['mobile_data'])
# Override values with mobile data if available
if 'filters' in mobile_data:
filter_upload = mobile_data.get('filters', [])
if 'filters2' in mobile_data:
filter_upload2 = mobile_data.get('filters2', [])
if 'filters3' in mobile_data:
filter_upload3 = mobile_data.get('filters3', [])
if 'duplicate_images' in mobile_data and mobile_data['duplicate_images']:
duplicate_images = mobile_data.get('duplicate_images', [])
except json.JSONDecodeError as e:
app.logger.error(f"Error parsing mobile data: {str(e)}")
except:
except Exception as e:
app.logger.error(f"Upload Form Data Parsing Error: {str(e)}")
flash('Fehler beim Verarbeiten der Formulardaten. Bitte versuchen Sie es erneut.', 'error')
return redirect(url_for(success_redirect_endpoint))
@@ -5406,7 +5332,7 @@ def upload_item():
filter_upload = expand_filter_selection(filter_upload, 1)
filter_upload2 = expand_filter_selection(filter_upload2, 2)
# Validation
# Base Validation
if not name or not ort or not beschreibung:
error_msg = 'Bitte füllen Sie alle erforderlichen Felder aus'
if is_mobile:
@@ -5442,9 +5368,8 @@ def upload_item():
flash(error_msg, 'error')
return redirect(url_for('library_admin'))
# Only check for images if not duplicating and no duplicate images provided and no book cover
# For library mode, skip this check as images come only from ISBN fetch
if upload_mode != 'library' and not is_duplicating and not images and not duplicate_images and not book_cover_image:
# Strict Image Validation (Since duplication is removed)
if upload_mode != 'library' and not images and not book_cover_image:
error_msg = 'Bitte laden Sie mindestens ein Bild hoch'
if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400
@@ -5453,36 +5378,20 @@ def upload_item():
return redirect(url_for(success_redirect_endpoint))
primary_code_raw = request.form.get('code_4', '').strip()
individual_codes_raw = request.form.get('individual_codes', '').strip()
all_item_codes = []
if primary_code_raw:
all_item_codes.append(primary_code_raw)
if individual_codes_raw:
extra_codes = [c.strip() for c in individual_codes_raw.replace('\r', '').split('\n') if c.strip()]
for c in extra_codes:
if individual_codes:
for c in individual_codes:
if c not in all_item_codes:
all_item_codes.append(c)
if len(all_item_codes) > 0:
item_count = len(all_item_codes)
else:
try:
item_count = int(request.form.get('item_count', 1))
except ValueError:
item_count = 1
if upload_mode != 'library' and not is_duplicating and not images and not duplicate_images and not book_cover_image:
error_msg = 'Bitte laden Sie mindestens ein Bild hoch'
if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400
else:
flash(error_msg, 'error')
return redirect(url_for(success_redirect_endpoint))
# Check unique codes
for code in all_item_codes:
if not it.is_code_unique(code):
app.logger.info(f"DEBUG: Code '{code}' is not unique.")
@@ -5492,12 +5401,12 @@ def upload_item():
error_msg = 'Der Code wird bereits verwendet. Umleitung zum Eintrag.'
if is_mobile:
return jsonify({
'success': False,
'success': False,
'message': error_msg,
'existing_item_id': str(existing_item['_id']),
'redirect_to_item': True
}), 400
flash(error_msg, 'info')
return redirect(url_for(success_redirect_endpoint, open_item=str(existing_item['_id'])))
@@ -5509,14 +5418,11 @@ def upload_item():
return redirect(url_for(success_redirect_endpoint))
def generate_unique_batch_code(base_code, position):
"""Generate a unique code for every item in a batch if no specific code is provided."""
if not base_code:
return None
candidate = base_code if position == 1 else f"{base_code}"
if it.is_code_unique(candidate):
return candidate
suffix = 1
while suffix <= 1000:
alternative = f"{candidate}"
@@ -5531,14 +5437,11 @@ def upload_item():
skipped_count = 0
upload_session_id = str(uuid.uuid4())[:8]
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)}")
# 1. Process Manual Image Uploads
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 not image or not image.filename:
skipped_count += 1
continue
@@ -5556,9 +5459,7 @@ def upload_item():
continue
secure_name = secure_filename(image.filename)
image.seek(0)
image_bytes = image.read()
if not image_bytes:
@@ -5593,7 +5494,6 @@ def upload_item():
)
image_filenames.append(new_filename)
processed_count += 1
final_size_kb = len(optimized_io.getvalue()) / 1024
app.logger.info(
@@ -5603,79 +5503,13 @@ def upload_item():
app.logger.error(f"{image_log_prefix} Processing failed: {str(e)}")
error_count += 1
app.logger.info(f"Upload session {upload_session_id} completed: {processed_count} processed, {error_count} errors, {skipped_count} skipped")
# 1. Duplikate verarbeiten (Direkt via MongoDB / GridFS)
if duplicate_images:
app.logger.info(f"Processing {len(duplicate_images)} duplicate images from GridFS: {duplicate_images}")
duplicate_image_copies = []
placeholder_used = False
for i, dup_img in enumerate(duplicate_images):
try:
# Suche das Originalbild direkt in der GridFS-Datenbank
existing_file = fs.find_one({"filename": dup_img})
# Fallback: Falls der exakte Name nicht gefunden wird, suche über Regex nach dem Basisnamen
if not existing_file:
base_name = re.sub(r'_\d+(?=\.[^.]+$)', '', dup_img) # Entfernt Suffixe wie _800
base_name_no_ext = os.path.splitext(base_name)[0]
if len(base_name_no_ext) > 5:
app.logger.info(f"Trying regex search for base name: {base_name_no_ext}")
existing_file = fs.find_one({"filename": {"$regex": f"^{re.escape(base_name_no_ext)}"}})
new_filename = f"{uuid.uuid4().hex}_{int(time.time())}.webp"
if existing_file:
app.logger.info(f"Found image in GridFS for {dup_img}. Duplicating...")
# Bilddaten in den Speicher laden und direkt als neue Datei in GridFS ablegen
image_data = existing_file.read()
fs.put(
image_data,
filename=new_filename,
content_type=existing_file.content_type,
metadata={'original_duplicate_of': dup_img}
)
duplicate_image_copies.append(new_filename)
processed_count += 1
else:
app.logger.warning(f"Could not find {dup_img} in GridFS. Using placeholder.")
placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.png')
if os.path.exists(placeholder_path):
with open(placeholder_path, 'rb') as pf:
placeholder_data = pf.read()
fs.put(
placeholder_data,
filename=new_filename,
content_type='image/png',
metadata={'is_placeholder': True}
)
duplicate_image_copies.append(new_filename)
placeholder_used = True
except Exception as e:
app.logger.error(f"Error duplicating image {dup_img}: {str(e)}")
error_count += 1
if placeholder_used:
app.logger.warning("Used placeholders for some missing images during duplication")
image_filenames.extend(duplicate_image_copies)
# 2. Buchcover verarbeiten
# 2. Process Book Cover
if book_cover_image:
new_filename = f"{uuid.uuid4().hex}_{int(time.time())}_book_cover.webp"
# Prüfen, ob das Cover noch als lokale temporäre Datei existiert (z.B. von einer API geladen)
local_path = os.path.join(app.config.get('UPLOAD_FOLDER', ''), book_cover_image)
try:
if os.path.exists(local_path):
# Lokales Bild einlesen, zu WebP optimieren und in GridFS schieben
with Image.open(local_path) as img:
if img.mode not in ('RGB', 'RGBA'):
img = img.convert('RGBA')
@@ -5693,7 +5527,6 @@ def upload_item():
image_filenames.append(new_filename)
app.logger.info(f"Processed local book cover and saved to GridFS: {new_filename}")
else:
# Falls es bereits in GridFS liegt, einfach duplizieren
existing_cover = fs.find_one({"filename": book_cover_image})
if existing_cover:
fs.put(
@@ -5709,7 +5542,10 @@ def upload_item():
except Exception as e:
app.logger.error(f"Error processing book cover {book_cover_image}: {str(e)}")
# 3. Item-Erstellung (Logik bleibt identisch, da it.add_item nun die GridFS-Filenames erhält)
app.logger.info(
f"Upload session {upload_session_id} completed: {processed_count} processed, {error_count} errors, {skipped_count} skipped")
# 3. Item Creation
predefined_locations = it.get_predefined_locations()
if ort and ort not in predefined_locations:
it.add_predefined_location(ort)
@@ -5735,7 +5571,6 @@ def upload_item():
parent_item_id = str(created_item_ids[0]) if created_item_ids else None
# image_filenames enthält jetzt ausschließlich GridFS-Filenames (oder ObjectIds, je nach deinem Setup)
item_id = it.add_item(
name, ort, beschreibung, image_filenames, filter_upload,
filter_upload2, filter_upload3,
@@ -5781,8 +5616,8 @@ def upload_item():
'codes': created_item_ids and [it.get_item(cid).get('Code_4') for cid in created_item_ids] or []
}
)
except Exception:
app.logger.warning('Audit write failed for library_item_created')
except Exception as e:
app.logger.warning(f"Audit write failed for library_item_created: {str(e)}")
flash(success_msg, 'success')
return redirect(url_for(success_redirect_endpoint))
@@ -5794,121 +5629,6 @@ def upload_item():
flash(error_msg, 'error')
return redirect(url_for(success_redirect_endpoint))
@app.route('/duplicate_item', methods=['POST'])
def duplicate_item():
"""
Route for duplicating an existing item.
Returns JSON response with success status.
Enhanced for mobile browser compatibility.
Returns:
flask.Response: JSON response with success status and data
"""
try:
# Check authentication
if 'username' not in session:
return jsonify({'success': False, 'message': 'Nicht angemeldet'}), 401
# Check if user is admin
username = session['username']
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'))
# Detect if request is from mobile device
is_mobile = 'Mobile' in request.headers.get('User-Agent', '')
is_ios = 'iPhone' in request.headers.get('User-Agent', '') or 'iPad' in request.headers.get('User-Agent', '')
# Log mobile duplication for debugging
if is_mobile:
app.logger.info(f"Mobile duplication from {request.headers.get('User-Agent', 'unknown')} by {encrypt_text(username)}")
# Get original item ID
original_item_id = request.form.get('original_item_id')
if not original_item_id:
return jsonify({'success': False, 'message': 'Ursprungs-Element-ID fehlt'}), 400
# Fetch original item data
original_item = it.get_item(original_item_id)
if not original_item:
return jsonify({'success': False, 'message': 'Ursprungs-Element nicht gefunden'}), 404
# Process filters as arrays (same as stored in database)
filter1_array = original_item.get('Filter', [])
filter2_array = original_item.get('Filter2', [])
filter3_array = original_item.get('Filter3', [])
# Ensure filters are arrays
if not isinstance(filter1_array, list):
filter1_array = [filter1_array] if filter1_array else []
if not isinstance(filter2_array, list):
filter2_array = [filter2_array] if filter2_array else []
if not isinstance(filter3_array, list):
filter3_array = [filter3_array] if filter3_array else []
# Verify image paths for mobile devices to avoid issues with non-existent images
images = original_item.get('Images', [])
verified_images = []
if is_mobile:
for img in images:
img_path = os.path.join(app.config['UPLOAD_FOLDER'], img)
if os.path.exists(img_path) and os.path.isfile(img_path):
verified_images.append(img)
else:
app.logger.warning(f"Image not found for duplication: {img}")
# If we lost images in verification, log it
if len(verified_images) < len(images):
app.logger.warning(f"Only {len(verified_images)} of {len(images)} images verified for mobile duplication")
else:
verified_images = images
# For iOS devices, add more diagnostics and reduce data size if needed
if is_ios:
# Check if images exist (we now use main images as thumbnails)
images_exist = []
for img in verified_images[:5]: # Only check first 5 to save time
img_path = os.path.join(app.config['UPLOAD_FOLDER'], img)
if os.path.exists(img_path):
images_exist.append(True)
else:
images_exist.append(False)
# Log detailed diagnostics
app.logger.info(f"iOS duplication details: {len(verified_images)} images, "
f"images available: {all(images_exist)}, "
f"filter sizes: {len(filter1_array)}, {len(filter2_array)}, {len(filter3_array)}")
return jsonify({
'success': True,
'message': 'Duplizierungsdaten erfolgreich vorbereitet',
'item_data': {
'name': original_item.get('Name', ''),
'description': original_item.get('Beschreibung', ''),
'location': original_item.get('Ort', ''),
'room': original_item.get('Raum', ''),
'category': original_item.get('Kategorie', ''),
'year': original_item.get('Anschaffungsjahr', ''),
'cost': original_item.get('Anschaffungskosten', ''),
'filter1': filter1_array,
'filter2': filter2_array,
'filter3': filter3_array,
'images': verified_images, # Using verified images instead of original
'isMobile': is_mobile,
'isIOS': is_ios
}
})
except Exception as e:
print(f"Error in duplicate_item: {e}")
return jsonify({'success': False, 'message': 'Serverfehler beim Duplizieren'}), 500
def _soft_delete_item_groups(db, root_item_ids, username):
"""Soft-delete one or more item groups and their borrow records."""
now = datetime.datetime.now()
@@ -6216,174 +5936,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:
@@ -8158,7 +7710,7 @@ def user_del():
fullname = None
users_list.append({
'username': username, # Username ist plain in DB, kein decrypt_text() notwendig
'username': decrypt_text(username),
'admin': user.get('Admin', False),
'fullname': fullname,
'name': name,
@@ -8199,7 +7751,7 @@ def delete_user():
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
tenant_id = session.get('tenant_id')
db = _get_tenant_db(client, tenant_id)
db = us._get_tenant_db(client, tenant_id)
ausleihungen = db['ausleihungen']
items_col = db['items']
@@ -10211,8 +9763,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 %}
File diff suppressed because it is too large Load Diff
+6 -80
View File
@@ -819,7 +819,7 @@
flex: 0 0 auto;
}
.ausleihen, .edit-button, .delete-button, .details-button, .duplicate-button, .schedule-button, .damage-button {
.ausleihen, .edit-button, .delete-button, .details-button, .schedule-button, .damage-button {
padding: 10px 18px;
border: none;
border-radius: 5px;
@@ -942,27 +942,6 @@
box-shadow: 0 2px 4px rgba(23, 162, 184, 0.3);
}
/* Duplicate Button */
.duplicate-button {
background-color: #6f42c1;
color: white;
border: 2px solid #6f42c1;
}
.duplicate-button:hover {
background-color: #5a32a3;
border-color: #4e2a8e;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(111, 66, 193, 0.3);
color: white;
text-decoration: none;
}
.duplicate-button:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(111, 66, 193, 0.3);
}
.damage-button {
background-color: #fd7e14;
color: white;
@@ -1645,7 +1624,7 @@
gap: 10px !important;
}
.ausleihen, .edit-button, .delete-button, .details-button, .duplicate-button, .schedule-button {
.ausleihen, .edit-button, .delete-button, .details-button, .schedule-button {
width: 100% !important;
min-height: 44px !important;
padding: 12px 20px !important;
@@ -1777,7 +1756,7 @@
justify-content: flex-start;
}
.ausleihen, .edit-button, .delete-button, .details-button, .duplicate-button, .schedule-button {
.ausleihen, .edit-button, .delete-button, .details-button, .schedule-button {
padding: 10px 20px;
font-size: 0.9rem;
min-width: 110px;
@@ -1790,7 +1769,7 @@
gap: 8px;
}
.ausleihen, .edit-button, .delete-button, .details-button, .duplicate-button, .schedule-button {
.ausleihen, .edit-button, .delete-button, .details-button, .schedule-button {
padding: 8px 16px;
font-size: 0.85rem;
min-width: 95px;
@@ -1808,7 +1787,7 @@
gap: 15px;
}
.ausleihen, .edit-button, .delete-button, .details-button, .duplicate-button, .schedule-button {
.ausleihen, .edit-button, .delete-button, .details-button, .schedule-button {
padding: 12px 24px;
font-size: 1rem;
min-width: 120px;
@@ -1980,7 +1959,7 @@
}
/* Standardize form and document buttons */
.save-button, .cancel-button, .remove-book-cover-button, .remove-duplicate-image-button,
.save-button, .cancel-button, .remove-book-cover-button,
.import-book-button, .fetch-isbn-button, .nav-back-button {
display: inline-flex;
align-items: center;
@@ -3465,9 +3444,6 @@ document.addEventListener('DOMContentLoaded', ()=>{
{% if current_permissions.actions.get('can_edit', False) %}
<button class="edit-button" onclick="openEditModalForSelectedUnit('${item._id}', 'specific-item-card-${item._id}')">Bearbeiten</button>
{% endif %}
{% if current_permissions.actions.get('can_insert', False) %}
<button class="duplicate-button" onclick="duplicateItem('${item._id}')">Duplizieren</button>
{% endif %}
{% if current_permissions.pages.get('admin_school_settings', False) %}
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Reservieren</button>` : ''}
{% endif %}
@@ -4244,7 +4220,6 @@ document.addEventListener('DOMContentLoaded', ()=>{
: `<button class="ausleihen disabled-button" disabled>Ausgeliehen</button>`
}
<button class="edit-button" onclick="openEditModalForSelectedUnit('${item._id}', 'specific-item-modal-${item._id}')">Bearbeiten</button>
<button class="duplicate-button" onclick="duplicateItem('${item._id}')">Duplizieren</button>
${damageReports && damageReports.length > 0 ? `<button class="damage-button" onclick="markDamageAsRepaired('${item._id}')">Repariert</button>` : `<button class="damage-button" onclick="registerDamage('${item._id}')">Schaden melden</button>`}
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Reservieren</button>` : ''}
<form method="POST" action="/delete_item/${item._id}" style="display:inline;" onsubmit="return confirm('Sind Sie sicher?')">
@@ -5044,55 +5019,6 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
}
// Duplication function
function duplicateItem(itemId) {
// Show loading indicator
const loadingDiv = document.createElement('div');
loadingDiv.innerHTML = `
<div style="position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.5); z-index: 10000; display: flex;
align-items: center; justify-content: center;">
<div class="modal-dialog-white">
<div>Element wird dupliziert...</div>
<div class="modal-content-margin">
<div class="spinner"></div>
</div>
</div>
</div>
`;
document.body.appendChild(loadingDiv);
// Create form data for the duplicate_item request
const formData = new FormData();
formData.append('original_item_id', itemId);
// Send duplication request to get item data
fetch('/duplicate_item', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
document.body.removeChild(loadingDiv);
if (data.success) {
// Store duplication data in sessionStorage for the upload page
sessionStorage.setItem('duplicateItemData', JSON.stringify(data.item_data));
// Redirect to upload admin page
window.location.href = '/upload_admin?duplicate=true';
} else {
alert('Fehler beim Duplizieren: ' + (data.message || 'Unbekannter Fehler'));
}
})
.catch(error => {
document.body.removeChild(loadingDiv);
console.error('Error duplicating item:', error);
alert('Fehler beim Duplizieren des Elements. Bitte versuchen Sie es erneut.');
});
}
// Helper functions for appointment display
function formatAppointmentDate(dateString) {
if (!dateString) return '';
@@ -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 %}
+20 -140
View File
@@ -11,59 +11,6 @@
{% block title %}{{ page_title|default('Artikel hochladen') }} - Inventarsystem{% endblock %}
{% block content %}
{% if duplicate_data and duplicate_data.images %}
<!-- Hidden server-side values for duplication, will be processed by JavaScript -->
<div id="server-duplicate-data"
data-name="{{ duplicate_data.name|default('') }}"
data-description="{{ duplicate_data.description|default('') }}"
data-location="{{ duplicate_data.location|default('') }}"
data-room="{{ duplicate_data.room|default('') }}"
data-year="{{ duplicate_data.year|default('') }}"
data-cost="{{ duplicate_data.cost|default('') }}"
data-images="{{ duplicate_data.images|tojson|safe }}"
data-filter1="{{ duplicate_data.filter1|tojson|safe if duplicate_data.filter1 else '[]' }}"
data-filter2="{{ duplicate_data.filter2|tojson|safe if duplicate_data.filter2 else '[]' }}"
data-filter3="{{ duplicate_data.filter3|tojson|safe if duplicate_data.filter3 else '[]' }}"
data-original-id="{{ duplicate_data.original_id|default('') }}"
style="display:none;">
</div>
<script>
// Pre-initialize duplicate data from server
var serverDuplicateData = null;
const dataElement = document.getElementById('server-duplicate-data');
if (dataElement) {
// Safely parse JSON arrays with error handling
function safeJsonParse(jsonStr, defaultValue = []) {
try {
if (!jsonStr) return defaultValue;
return JSON.parse(jsonStr);
} catch (e) {
console.error("Error parsing JSON:", e, "Value was:", jsonStr);
return defaultValue;
}
}
// Get images data and log it for debugging
const imagesData = dataElement.getAttribute('data-images') || "[]";
console.log("Raw images data attribute:", imagesData);
serverDuplicateData = {
name: dataElement.getAttribute('data-name') || "",
description: dataElement.getAttribute('data-description') || "",
location: dataElement.getAttribute('data-location') || "",
room: dataElement.getAttribute('data-room') || "",
year: dataElement.getAttribute('data-year') || "",
cost: dataElement.getAttribute('data-cost') || "",
images: safeJsonParse(imagesData),
filter1: safeJsonParse(dataElement.getAttribute('data-filter1')),
filter2: safeJsonParse(dataElement.getAttribute('data-filter2')),
filter3: safeJsonParse(dataElement.getAttribute('data-filter3')),
original_id: dataElement.getAttribute('data-original-id') || ""
};
console.log("Server-provided duplicate data:", serverDuplicateData);
}
</script>
{% endif %}
<style>
/* Book information display styles */
@@ -296,38 +243,11 @@
color: #721c24 !important;
}
/* Duplicate code popup styling */
.popup-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
z-index: 10000;
display: flex;
justify-content: center;
align-items: center;
animation: overlayFadeIn 0.3s ease;
}
@keyframes overlayFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.duplicate-code-popup {
background-color: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.3);
max-width: 500px;
width: 90%;
text-align: center;
position: relative;
animation: popupSlideIn 0.3s ease;
}
@keyframes popupSlideIn {
from {
opacity: 0;
@@ -916,7 +836,7 @@
<!-- Options will be loaded by JavaScript -->
</select>
</div>
<div class="formupload_admin-group">
<div class="form-group">
<label for="filter2-4">Wert 4:</label>
<select id="filter2-4" name="filter2" class="filter-dropdown-select">
<option value="">-- Optional --</option>
@@ -954,11 +874,13 @@
<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 %}>
<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>
<!-- Image upload -->
<div class="form-group">
<label for="images">Bilder:</label>
<input type="file" id="images" name="images" accept=".jpg, .jpeg, .png, .gif" multiple>
<div class="allowed-formats">Erlaubte Formate: JPG, JPEG, PNG, GIF</div>
<!-- Add image preview area -->
<div class="image-preview-container" id="image-preview-container"></div>
</div>
@@ -1876,11 +1798,11 @@
if (existingPopup) {
existingPopup.remove();
}
// Create popup overlay
const overlay = document.createElement('div');
overlay.className = 'popup-overlay';
// Create popup content
overlay.innerHTML = `
<div class="duplicate-code-popup">
@@ -1893,9 +1815,9 @@
<button class="popup-close-button" onclick="this.closest('.popup-overlay').remove()">Verstanden</button>
</div>
`;
document.body.appendChild(overlay);
// Auto-remove after 10 seconds
setTimeout(() => {
if (overlay.parentNode) {
@@ -1935,28 +1857,25 @@
function setupImagePreview() {
const imageInput = document.getElementById('images');
const previewContainer = document.getElementById('image-preview-container');
if (imageInput && previewContainer) {
imageInput.addEventListener('change', function(e) {
previewContainer.innerHTML = '';
const files = e.target.files;
// Validate file types before preview
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif',
'video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/x-matroska',
'video/webm', 'video/x-flv', 'video/mp4', 'video/3gpp'];
let hasInvalidFile = false;
// Validate file types before preview (Strictly Images)
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'];
for (let i = 0; i < files.length; i++) {
if (!allowedTypes.includes(files[i].type)) {
hasInvalidFile = true;
// Clear the file input to prevent submission
imageInput.value = '';
previewContainer.innerHTML = '<div class="error-message">Fehler: Datei "' + files[i].name + '" hat ein nicht unterstütztes Format. Erlaubte Formate: JPG, JPEG, PNG, GIF, MP4, MOV, AVI, MKV, WEBM, FLV, M4V, 3GP</div>';
previewContainer.innerHTML = '<div class="error-message">Fehler: Datei "' + files[i].name + '" hat ein nicht unterstütztes Format. Erlaubte Formate: JPG, JPEG, PNG, GIF</div>';
return; // Stop processing
}
}
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.type.startsWith('image/')) {
@@ -1971,17 +1890,6 @@
previewContainer.appendChild(preview);
};
reader.readAsDataURL(file);
} else if (file.type.startsWith('video/')) {
const preview = document.createElement('div');
preview.className = 'image-preview video-preview';
preview.innerHTML = `
<div class="video-placeholder">
<div class="video-icon">🎥</div>
<div class="video-name">${file.name}</div>
</div>
<button type="button" class="remove-image" onclick="removeImagePreview(this, ${i})">×</button>
`;
previewContainer.appendChild(preview);
}
}
});
@@ -2047,34 +1955,6 @@
}
});
}
// Handle duplication data if available (from sessionStorage or server)
if (typeof prefillFormWithDuplicateData === 'function') {
setTimeout(() => {
prefillFormWithDuplicateData();
}, 500); // Wait for all dropdowns to load
}
});
// Initialize duplication data - handle both server-side and sessionStorage data
let duplicateData = null;
// First check if serverDuplicateData is defined by the script above
if (typeof serverDuplicateData !== 'undefined' && serverDuplicateData) {
console.log("Using server-provided duplicateData:", serverDuplicateData);
duplicateData = serverDuplicateData;
// Ensure images is properly handled as an array
if (duplicateData.images) {
if (typeof duplicateData.images === 'string') {
try {
duplicateData.images = JSON.parse(duplicateData.images);
} catch (e) {
console.error("Error parsing duplicate images array:", e);
}
}
console.log("Parsed duplicate images successfully.");
}
}
</script>
{% endblock %}