diff --git a/Web/app.py b/Web/app.py index 943a84b..bd479fa 100755 --- a/Web/app.py +++ b/Web/app.py @@ -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__)) @@ -46,7 +50,7 @@ import Web.modules.inventarsystem.pdf_export as pdf_export import Web.modules.inventarsystem.excel_export as excel_export import datetime from apscheduler.schedulers.background import BackgroundScheduler -from bson.objectid import ObjectId +from bson.objectid import ObjectId, InvalidId from urllib.parse import urlparse, urlunparse import requests import csv @@ -225,12 +229,95 @@ def rollover_student_card_classes(dry_run=False, *, max_class=None, graduate_lab if client: client.close() - summary = {'examined': examined, 'updated': updated, 'failures': failures, 'dry_run': bool(dry_run)} + +@app.route('/api/library_return_by_code', methods=['POST']) +def api_library_return_by_code(): + """ + Return a library item by scanning its code only (no student card required). + This marks active ausleihungen for the item as completed and updates item status. + """ + if 'username' not in session: + return jsonify({'ok': False, 'message': 'Nicht angemeldet.'}), 401 + if not cfg.MODULES.is_enabled('library'): + return jsonify({'ok': False, 'message': 'Bibliotheks-Modul ist deaktiviert.'}), 403 + + payload = request.get_json(silent=True) or {} + item_code_raw = str(payload.get('item_code') or payload.get('code') or '').strip() + if not item_code_raw: + return jsonify({'ok': False, 'message': 'Mediencode fehlt.'}), 400 + + normalized_isbn = normalize_and_validate_isbn(item_code_raw) + normalized_code = item_code_raw.upper() + + client = None try: - _append_audit_event_standalone('student_cards_rollover', summary) - except Exception: - app.logger.warning('Audit write failed for student_cards_rollover') - return summary + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + items_col = db['items'] + ausleihungen_col = db['ausleihungen'] + + query_or = [ + {'Code_4': item_code_raw}, + {'Code_4': normalized_code}, + ] + if normalized_isbn: + query_or.append({'ISBN': normalized_isbn}) + + item_doc = items_col.find_one({ + 'ItemType': {'$in': LIBRARY_ITEM_TYPES}, + '$or': query_or + }) + + if not item_doc: + return jsonify({'ok': False, 'message': 'Kein Bibliotheksmedium für diesen Code gefunden.'}), 404 + + item_id = str(item_doc['_id']) + now = datetime.datetime.now() + + # If item already available -> nothing to return + if item_doc.get('Verfuegbar', True): + return jsonify({'ok': False, 'message': 'Dieses Medium ist nicht als ausgeliehen markiert.'}), 409 + + # Mark active ausleihungen as completed + update_result = ausleihungen_col.update_many( + {'Item': item_id, 'Status': 'active'}, + {'$set': { + 'Status': 'completed', + 'End': now, + 'LastUpdated': now + }} + ) + + # Update item status to available + borrower_name = str(item_doc.get('User') or '').strip() or '' + it.update_item_status(item_id, True, borrower_name) + + _append_audit_event_standalone( + event_type='ausleihung_returned_by_code', + payload={ + 'channel': 'library_return_code', + 'item_id': item_id, + 'item_name': item_doc.get('Name', ''), + 'completed_records': update_result.modified_count, + 'performed_by': session.get('username') + } + ) + + return jsonify({ + 'ok': True, + 'action': 'returned', + 'item_id': item_id, + 'item_name': item_doc.get('Name', ''), + 'completed_records': update_result.modified_count, + 'message': f"{item_doc.get('Name', 'Medium')} wurde zurückgegeben." + }), 200 + except Exception as e: + app.logger.error(f"Error in library return by code: {e}") + return jsonify({'ok': False, 'message': 'Fehler beim Verarbeiten der Rückgabe.'}), 500 + finally: + if client: + client.close() + # Admin route to trigger rollover manually @@ -379,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"] @@ -3230,7 +3338,7 @@ def api_library_items(): ausleihungen_db = db['ausleihungen'] query = { - 'ItemType': {'$in': ['book', 'cd', 'dvd', 'schoolbook', 'schulbuch', 'Buch', 'Schulbuch']}, + 'ItemType': {'$in': ['book', 'cd', 'CD', 'DVD', 'dvd', 'schoolbook', 'schulbuch', 'Buch', 'Schulbuch']}, 'IsGroupedSubItem': {'$ne': True}, 'Deleted': {'$ne': True} } @@ -3249,7 +3357,12 @@ def api_library_items(): 'User': 1, 'Ort': 1, 'Beschreibung': 1, - 'Image': 1 + 'Image': 1, + 'SeriesGroupId': 1, + 'SeriesCount': 1, + 'SeriesPosition': 1, + 'IsGroupedSubItem': 1, + 'ParentItemId': 1, } total_count = items_db.count_documents(query) @@ -3277,6 +3390,10 @@ def api_library_items(): 'Beschreibung': 1, 'Image': 1, 'ParentItemId': 1, + 'SeriesGroupId': 1, + 'SeriesCount': 1, + 'SeriesPosition': 1, + 'IsGroupedSubItem': 1, } child_items = list(items_db.find({ 'ParentItemId': {'$in': parent_ids_list}, @@ -3390,6 +3507,48 @@ def api_library_items(): return jsonify({'error': 'An error occurred while fetching library items'}), 500 +@app.route('/api/library_group/') +def api_library_group(series_group_id): + """Fetch all items belonging to one library series group.""" + if 'username' not in session: + return jsonify({'items': []}), 401 + + try: + client = MongoClient(MONGODB_HOST, MONGODB_PORT) + db = client[MONGODB_DB] + items_col = db['items'] + + query = { + 'SeriesGroupId': series_group_id, + 'Deleted': {'$ne': True}, + 'ItemType': {'$in': ['book', 'cd', 'dvd', 'schoolbook', 'schulbuch', 'Buch', 'Schulbuch']}, + } + projection = { + 'Name': 1, + 'ISBN': 1, + 'Code_4': 1, + 'Code4': 1, + 'ItemType': 1, + 'Ort': 1, + 'Beschreibung': 1, + 'SeriesGroupId': 1, + 'SeriesCount': 1, + 'SeriesPosition': 1, + 'IsGroupedSubItem': 1, + 'ParentItemId': 1, + } + + items = list(items_col.find(query, projection).sort([('SeriesPosition', 1), ('Name', 1), ('_id', 1)])) + for item in items: + item['_id'] = str(item['_id']) + + client.close() + return jsonify({'items': items, 'count': len(items), 'series_group_id': series_group_id}) + except Exception as exc: + app.logger.error('Error loading library group %s: %s', series_group_id, exc) + return jsonify({'items': [], 'message': 'Gruppe konnte nicht geladen werden.'}), 500 + + @app.route('/api/library_scan_action', methods=['POST']) def api_library_scan_action(): """ @@ -3654,6 +3813,7 @@ def api_item_detail(item_id):

{html.escape(str(item.get('Name', 'Untitled')))}

ISBN: {html.escape(str(item.get('ISBN', item.get('Code4', '-'))))}

Anzahl: {html.escape(str(item.get('SeriesCount', '-')))}

+

Code: {html.escape(str(item.get('Code_4', '-')))}

Ort: {html.escape(str(item.get('Ort', '-')))}

Typ: {html.escape(str(item.get('ItemType', '-')))}

Kategorie: {html.escape(str(item.get('library_category', '-')))}

@@ -5177,17 +5337,13 @@ def upload_item(): fs = get_gridfs() - can_access_admin_home = _page_access_allowed(permissions, 'home_admin') and _action_access_allowed(permissions, 'can_manage_settings') - if can_access_admin_home: - success_redirect_endpoint = 'home_admin' - elif cfg.MODULES.is_enabled('library') and _page_access_allowed(permissions, 'home_library'): - success_redirect_endpoint = 'home_library' + if cfg.MODULES.is_enabled('library') and sanitize_form_value(request.form.get('item_type_input', '')) != "other": + success_redirect_endpoint = 'library' else: success_redirect_endpoint = '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 request for debugging if is_mobile: @@ -5399,10 +5555,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 @@ -5650,7 +5806,7 @@ def upload_item(): app.logger.warning('Audit write failed for library_item_created') flash(success_msg, 'success') - return redirect(url_for(success_redirect_endpoint, highlight_item=str(item_id))) + return redirect(url_for(success_redirect_endpoint)) else: error_msg = 'Fehler beim Hinzufügen des Elements' if is_mobile: @@ -6081,142 +6237,182 @@ def bulk_delete_items(): if client: client.close() - -@app.route('/edit_item/', 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 - """ +@app.route('/item_edit/', methods=['GET', 'POST']) +def item_edit(id): if 'username' not in session: - flash('Nicht angemeldet.', 'error') + if request.method == 'POST' and request.is_json: + return jsonify({'success': False, 'message': 'Nicht angemeldet.'}), 401 + flash('Bitte melden Sie sich an.', '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') + if request.method == 'POST' and request.is_json: + return jsonify({'success': False, 'message': 'Keine Berechtigung zum Bearbeiten.'}), 403 + flash('Keine Berechtigung zum Bearbeiten.', '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')) + try: + obj_id = ObjectId(id) + except InvalidId: + flash('Ungültige Element-ID.', 'error') + return redirect(url_for('home_admin')) - fs = get_gridfs() + current_item = it.get_item(obj_id) + if not current_item: + flash('Element in der Datenbank nicht gefunden.', 'error') + return redirect(url_for('home_admin')) + + # Bibliothek-Status ermitteln + library_module_active = cfg.MODULES.is_enabled('library') + is_lib_item = it.is_library_item(current_item) + show_library_features = library_module_active and is_lib_item + + # ------------------------------------------------------------------- + # GET METHOD + # ------------------------------------------------------------------- + if request.method == 'GET': + current_item['_id'] = str(current_item['_id']) + + base_code = current_item.get('Code_4', '') + individual_codes = [] + if current_item.get('SeriesGroupId'): + group_ids = it.get_group_item_ids(str(current_item['_id'])) + if group_ids: + for gid in group_ids: + g_item = it.get_item(gid) + c4 = g_item.get('Code_4') + if c4 and c4 != base_code: + individual_codes.append(c4) + + current_item['IndividualCodes'] = '\n'.join(individual_codes) + + return render_template( + 'edit_library.html', + username=session['username'], + item=current_item, + show_library_features=show_library_features, + library_module_enabled=library_module_active, + page_title=f"Bearbeiten: {current_item.get('Name', '')}" + ) + + # ------------------------------------------------------------------- + # POST METHOD + # ------------------------------------------------------------------- + redirect_target = request.referrer or url_for('home_admin') 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' + code_4 = sanitize_form_value(request.form.get('code_4')) + individual_codes_raw = request.form.get('individual_codes', '') - 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')) + individual_codes = [] + for c in individual_codes_raw.replace('\r', '').split('\n'): + clean_c = sanitize_form_value(c) + if clean_c and clean_c != code_4 and clean_c not in individual_codes: + individual_codes.append(clean_c) - current_item = it.get_item(id) - if not current_item: - flash('Element nicht gefunden', 'error') - return redirect(url_for('home_admin')) + all_codes_to_check = [code_4] + individual_codes - verfuegbar = current_item.get('Verfuegbar', True) + current_group_id = current_item.get('SeriesGroupId') + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db_instance = client[cfg.MONGODB_DB] + items_col = db_instance['items'] - images_to_keep = request.form.getlist('existing_images') + has_code_error = False + for code in all_codes_to_check: + if not code: + continue + existing = items_col.find_one({'Code_4': code, 'Deleted': {'$ne': True}}) + if existing: + is_same_item = str(existing['_id']) == str(id) + is_in_same_group = current_group_id and existing.get('SeriesGroupId') == current_group_id + if not is_same_item and not is_in_same_group: + flash(f'Der Code "{code}" wird bereits von einem anderen Artikel verwendet.', 'error') + has_code_error = True + break + client.close() - original_images = current_item.get('Images', []) + if has_code_error: + return redirect(redirect_target) - images = [img for img in original_images if img in images_to_keep] + if show_library_features: + # LIBRARY ITEM: Process ISBN/Medientyp/Category, preserve existing filters + isbn_raw = sanitize_form_value(request.form.get('isbn', '')) + item_isbn = normalize_and_validate_isbn(isbn_raw) if isbn_raw else '' + item_type = sanitize_form_value(request.form.get('item_type_input', current_item.get('ItemType', 'Buch'))) + library_category = sanitize_form_value(request.form.get('library_category', '')) + images = current_item.get('Images', []) - new_images = request.files.getlist('new_images') + filter1 = current_item.get('Filter', []) + filter2 = current_item.get('Filter2', []) + filter3 = current_item.get('Filter3', []) + else: + # NON-LIBRARY (INVENTORY) ITEM: Process Filter 1-3 from form + item_isbn = current_item.get('ISBN', '') + item_type = 'other' + library_category = current_item.get('library_category', '') - for image in new_images: - if image and image.filename: - is_allowed, error_message = allowed_file(image.filename, image) + filter1 = expand_filter_selection(sanitize_form_value(request.form.getlist('filter')), 1) + filter2 = expand_filter_selection(sanitize_form_value(request.form.getlist('filter2')), 2) + filter3 = sanitize_form_value(request.form.getlist('filter3')) - if is_allowed: - try: - secure_name = secure_filename(image.filename) + 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] - 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: + new_files = request.files.getlist('images') + if new_files and new_files[0].filename != '': + fs = get_gridfs() + for file in new_files: + if file and file.filename: + is_allowed, error_msg = allowed_file(file.filename, file) + if not is_allowed: + flash(error_msg, 'error') + return redirect(redirect_target) + try: + secure_name = secure_filename(file.filename) + file.seek(0) + image_bytes = file.read() + if not image_bytes: + 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 = 800 + if img.width > max_width: + ratio = max_width / img.width + img = img.resize((max_width, int(img.height * ratio)), 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, 'item_id': str(id)} + ) + images.append(new_filename) + except Exception as e: + app.logger.error(f"Image error for item {id}: {e}") + if ort and ort not in it.get_predefined_locations(): it.add_predefined_location(ort) - result = it.update_item( - id=id, + it.sync_group_codes(str(id), code_4, individual_codes) + + success = it.update_item( + id=str(id), name=name, ort=ort, beschreibung=beschreibung, images=images, - verfuegbar=verfuegbar, + verfuegbar=current_item.get('Verfuegbar', True), filter1=filter1, filter2=filter2, filter3=filter3, @@ -6225,16 +6421,16 @@ def edit_item(id): code_4=code_4, reservierbar=reservierbar, isbn=item_isbn, - item_type=item_type + item_type=item_type, + library_category=library_category ) - if result: - flash('Element erfolgreich aktualisiert (und ggf. Gruppe synchronisiert)', 'success') + if success: + flash('Artikel erfolgreich aktualisiert.', 'success') else: - flash('Fehler beim Aktualisieren des Elements', 'error') - - return redirect(url_for('home_admin')) + flash('Fehler beim Aktualisieren des Artikels.', 'error') + return redirect(redirect_target) @app.route('/update_group', methods=['POST']) def update_group(): @@ -6257,17 +6453,20 @@ def update_group(): # 1. Shared Fields (Group Logic) # These apply to every item in the group - shared_update = { - 'Name': data.get('name'), - 'Ort': data.get('ort'), - 'Beschreibung': data.get('beschreibung'), - 'Anschaffungsjahr': data.get('ansch_jahr'), - 'Anschaffungskosten': data.get('ansch_kost'), - 'Reservierbar': data.get('reservierbar'), - 'ISBN': data.get('isbn'), - 'ItemType': data.get('item_type'), - 'LastUpdated': datetime.datetime.now() - } + shared_update = {'LastUpdated': datetime.datetime.now()} + for source_key, target_key in ( + ('name', 'Name'), + ('ort', 'Ort'), + ('beschreibung', 'Beschreibung'), + ('ansch_jahr', 'Anschaffungsjahr'), + ('ansch_kost', 'Anschaffungskosten'), + ('reservierbar', 'Reservierbar'), + ('isbn', 'ISBN'), + ('item_type', 'ItemType'), + ): + value = data.get(source_key) + if value is not None: + shared_update[target_key] = value # 2. Individual Updates (Specific Code Logic) # Expected format: [{'id': '...', 'code_4': '...'}, ...] @@ -7519,35 +7718,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) @@ -7559,10 +8005,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), @@ -7573,7 +8020,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, @@ -7586,68 +8033,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(): """ @@ -9613,8 +10064,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) diff --git a/Web/modules/database/items.py b/Web/modules/database/items.py index f2c188a..fc24e13 100755 --- a/Web/modules/database/items.py +++ b/Web/modules/database/items.py @@ -20,12 +20,30 @@ Collection Structure: """ from bson.objectid import ObjectId from bson.errors import InvalidId +import uuid import datetime import Web.modules.database.settings as cfg from Web.modules.database.settings import MongoClient import Web.modules.inventarsystem.data_protection as dp +def is_library_item(item): + """ + Ermittelt zuverlässig, ob ein Objekt zur Bibliothek gehört. + Gibt True zurück, wenn ItemType ein Medientyp ist (Buch, Schulbuch, CD, DVD etc.) + ODER wenn is_library explizit True ist. + """ + if not item: + return False + + # 1. Prüfe zuerst den Medientyp (ItemType) + item_type = str(item.get('ItemType', '') or '').strip().lower() + if item_type and item_type not in ['other', 'general', 'none', 'null']: + return True + + # 2. Falls ItemType 'other' ist, prüfe das is_library Flag + return bool(item.get('is_library', False)) + def safe_decrypt_user(encrypted_user): """ Safely decrypt an encrypted username string. @@ -250,7 +268,10 @@ def get_group_item_ids(id): def update_item(id, name, ort, beschreibung, images, verfuegbar, filter1, filter2, filter3, - ansch_jahr, ansch_kost, code_4, reservierbar, isbn=None, item_type='general'): + ansch_jahr, ansch_kost, code_4, reservierbar, isbn="", item_type='other', library_category=""): + """ + Aktualisiert ein Objekt in MongoDB und setzt is_library korrekt basierend auf dem Medientyp. + """ try: client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) db = client[cfg.MONGODB_DB] @@ -258,29 +279,35 @@ def update_item(id, name, ort, beschreibung, images, verfuegbar, filter1, filter old_item = items.find_one({'_id': ObjectId(id)}) if not old_item: + client.close() return False series_group_id = old_item.get('SeriesGroupId') + # is_library automatisch anhand des neuen item_type bestimmen + is_lib = is_library_item({'ItemType': item_type}) + shared_update = { 'Name': name, 'Ort': ort, 'Beschreibung': beschreibung, - 'Images': images, - 'Filter': filter1, - 'Filter2': filter2, - 'Filter3': filter3, + 'Images': images if isinstance(images, list) else [], + 'Filter': filter1 if isinstance(filter1, list) else [], + 'Filter2': filter2 if isinstance(filter2, list) else [], + 'Filter3': filter3 if isinstance(filter3, list) else [], 'Anschaffungsjahr': ansch_jahr, 'Anschaffungskosten': ansch_kost, - 'Reservierbar': reservierbar, - 'ISBN': isbn, + 'Reservierbar': bool(reservierbar), + 'ISBN': str(isbn) if isbn else '', 'ItemType': item_type, - 'Verfuegbar': verfuegbar, + 'is_library': is_lib, + 'library_category': library_category, + 'Verfuegbar': bool(verfuegbar), 'LastUpdated': datetime.datetime.now() } specific_update = shared_update.copy() - specific_update['Code_4'] = code_4 + specific_update['Code_4'] = str(code_4) if code_4 else '' items.update_one({'_id': ObjectId(id)}, {'$set': specific_update}) @@ -1147,4 +1174,122 @@ def get_current_status(item_id, decrypt=True): return None except Exception as e: print(f"Error retrieving current status for item {item_id}: {e}") - return None \ No newline at end of file + return None + +def sync_group_codes(primary_obj_id, base_code, individual_codes_list): + """ + Synchronisiert die Barcodes einer Gruppe im korrekten Schema + (angelehnt an das 'Augenmodell groß'-Vorbild). + """ + if not base_code: + return False + + # Alle Ziel-Codes zusammenführen (Basis-Code an erster Stelle) + all_target_codes = [base_code] + for c in individual_codes_list: + if c and c not in all_target_codes: + all_target_codes.append(c) + + item_count = len(all_target_codes) + + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + items = db['items'] + + primary_item = items.find_one({'_id': ObjectId(primary_obj_id)}) + if not primary_item: + client.close() + return False + + group_id = primary_item.get('SeriesGroupId') + + # Wenn es mehr als 1 Item gibt und noch keine Gruppe existiert -> Neue GroupID erzeugen + if not group_id and item_count > 1: + group_id = str(uuid.uuid4()) + + # Wenn es nun eine Gruppe gibt (item_count > 1) + if item_count > 1: + # 1. Haupt-Item (Parent) aktualisieren + items.update_one( + {'_id': primary_item['_id']}, + {'$set': { + 'Code_4': base_code, + 'SeriesGroupId': group_id, + 'SeriesCount': item_count, + 'SeriesPosition': 1, + 'IsGroupedSubItem': False, + 'ParentItemId': None + }} + ) + + # Bestehende Gruppenmitglieder laden + existing_items = list(items.find({'SeriesGroupId': group_id})) + existing_map = {it.get('Code_4'): it for it in existing_items if + it.get('Code_4') and str(it['_id']) != str(primary_item['_id'])} + + # Alle verbleibenden Sub-Codes ab Position 2 abarbeiten + processed_sub_ids = [] + for idx, code in enumerate(all_target_codes[1:], start=2): + if code in existing_map: + # Existiert bereits in der Gruppe -> Nur Position und Count aktualisieren + sub_item = existing_map[code] + processed_sub_ids.append(sub_item['_id']) + items.update_one( + {'_id': sub_item['_id']}, + {'$set': { + 'SeriesCount': item_count, + 'SeriesPosition': idx, + 'IsGroupedSubItem': True, + 'ParentItemId': str(primary_item['_id']) + }} + ) + else: + # Neu hinzukommender Code -> Als Klon (Sub-Item) erstellen + new_sub = primary_item.copy() + if '_id' in new_sub: + del new_sub['_id'] + + new_sub.update({ + 'Code_4': code, + 'SeriesGroupId': group_id, + 'SeriesCount': item_count, + 'SeriesPosition': idx, + 'IsGroupedSubItem': True, + 'ParentItemId': str(primary_item['_id']), + 'LastUpdated': primary_item.get('LastUpdated') + }) + inserted_res = items.insert_one(new_sub) + processed_sub_ids.append(inserted_res.inserted_id) + + # Nicht mehr benötigte Sub-Items aus dieser Gruppe entfernen + for code, sub_item in existing_map.items(): + if sub_item['_id'] not in processed_sub_ids: + items.delete_one({'_id': sub_item['_id']}) + + else: + # Fall: Nur 1 einziges Item (keine Gruppe / Gruppe aufgelöst) + # Eventuelle alte Sub-Items dieser Gruppe löschen + if group_id: + items.delete_many({ + 'SeriesGroupId': group_id, + '_id': {'$ne': primary_item['_id']} + }) + + items.update_one( + {'_id': primary_item['_id']}, + {'$set': { + 'Code_4': base_code, + 'SeriesGroupId': None, + 'SeriesCount': 1, + 'SeriesPosition': 1, + 'IsGroupedSubItem': False, + 'ParentItemId': None + }} + ) + + client.close() + return True + except Exception as e: + print(f"Error syncing group codes: {e}") + return False \ No newline at end of file diff --git a/Web/modules/database/user.py b/Web/modules/database/user.py index 4dc7a10..2ccbb07 100755 --- a/Web/modules/database/user.py +++ b/Web/modules/database/user.py @@ -650,7 +650,7 @@ def add_user( safe_last_name = last_name.strip() if last_name else '' user_doc = { - 'Username': dp.encrypt_text(username), + 'Username': username, 'Password': hashing(password), 'Admin': (permission_preset == "full_access"), 'active_ausleihung': None, diff --git a/Web/templates/edit_inventar.html b/Web/templates/edit_inventar.html new file mode 100644 index 0000000..2e20b48 --- /dev/null +++ b/Web/templates/edit_inventar.html @@ -0,0 +1,1029 @@ + +{% extends "base.html" %} + +{% block title %}{{ page_title|default('Artikel bearbeiten') }} - Inventarsystem{% endblock %} + +{% block content %} + + + + + +
+
+

{{ page_title|default('Artikel bearbeiten') }}

+ +
+ + + + + {% if show_library_features %} + +
+ +
+ + + +
+ + +
+
+ {% endif %} + + +
+ + +
+ + +
+ + + + +
+ + +
+ + +
+ + +
+ +
+ + +
+ + +
+ + +
+ + + Der Scanner füllt zuerst den Basis-Code; weitere gescannte Codes werden hier automatisch zeilenweise angehängt. +
+ + {% if show_library_features %} + +
+

Medientyp

+
+ {% set current_type = item.ItemType if item.ItemType else '' %} + +
+ +

Kategorie / Fach:

+
+ +
+
+ {% else %} + +
+

Unterrichtsfach:

+
+ {% for i in range(1, 5) %} +
+ + +
+ {% endfor %} +
+ +

Jahrgangsstufe:

+
+ {% for i in range(1, 5) %} +
+ + +
+ {% endfor %} +
+ +

Schlagwörter:

+
+ {% for i in range(1, 5) %} +
+ + +
+ {% endfor %} +
+
+ {% endif %} + + +
+ + +
+ +
+ + +
+ + +
+ +
+ {% if item.Images %} + {% for img in item.Images %} +
+ + +
+ {% endfor %} + {% else %} + Keine Bilder vorhanden. + {% endif %} +
+ + + +
+
+ + +
+ + +
+ + +
+
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/Web/templates/edit_item_functions.html b/Web/templates/edit_item_functions.html deleted file mode 100755 index dbbfda1..0000000 --- a/Web/templates/edit_item_functions.html +++ /dev/null @@ -1,288 +0,0 @@ - - - diff --git a/Web/templates/edit_library.html b/Web/templates/edit_library.html new file mode 100644 index 0000000..d5bba10 --- /dev/null +++ b/Web/templates/edit_library.html @@ -0,0 +1,509 @@ + +{% extends "base.html" %} + +{% block title %}{{ page_title|default('Artikel bearbeiten') }} - Inventarsystem{% endblock %} + +{% block content %} + + +
+
+

{{ page_title|default('Artikel bearbeiten') }}

+
+ + + {% if show_library_features %} + +
+ +
+ + + +
+ + +
+
+ +
+

Medientyp

+
+ +
+

Bibliotheks-Kategorie:

+
+ +
+
+ {% endif %} + + +
+ + +
+ +
+ + + +
+ + + +
+
+ +
+ + +
+ +
+ +
+ + +
+ + +
+ +
+ + + Der Basis-Code steht oben. Alle weiteren Gruppenmitglieder werden hier untereinander aufgeführt. +
+ + {% if not show_library_features %} + +
+

Unterrichtsfach (Filter 1):

+
+ {% for idx in range(4) %} +
+ + +
+ {% endfor %} +
+ +

Jahrgangsstufe (Filter 2):

+
+ {% for idx in range(4) %} +
+ + +
+ {% endfor %} +
+ +

Schlagwort (Filter 3):

+
+ {% for idx in range(4) %} +
+ + +
+ {% endfor %} +
+
+ {% endif %} + + +
+ + +
+
+ + +
+ + {% if not show_library_features %} + +
+ + {% if item.Images and item.Images|length > 0 %} +
+ {% for img in item.Images %} +
+ Bild + +
+ {% endfor %} +
+ {% else %} +

Keine Bilder vorhanden.

+ {% endif %} + + + +
+ {% endif %} + +
+ + +
+ + +
+
+
+ + + +{% endblock %} \ No newline at end of file diff --git a/Web/templates/library_table.html b/Web/templates/library_table.html index bc1d773..a98be4a 100644 --- a/Web/templates/library_table.html +++ b/Web/templates/library_table.html @@ -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 { @@ -355,6 +357,28 @@ color: #6b7280; } + /* Small confirmation popup (toast) */ + .small-popup { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + background: rgba(17, 24, 39, 0.96); + color: #fff; + padding: 12px 16px; + border-radius: 8px; + box-shadow: 0 6px 24px rgba(2,6,23,0.6); + z-index: 2000; + display: flex; + gap: 10px; + align-items: center; + max-width: 90%; + font-size: 0.95em; + } + .small-popup.ok { background: rgba(16,185,129,0.95); color: #032; } + .small-popup.error { background: rgba(239,68,68,0.95); color: #210; } + .small-popup .close-x { margin-left: 8px; cursor: pointer; font-weight: 700; } + /* Modal styles */ .modal { display: none; @@ -478,14 +502,14 @@
Hinweis: Im Schnellmodus zuerst den Schülerausweis scannen, danach den Buch-/Mediencode. @@ -566,12 +600,12 @@ - + - - + + @@ -624,17 +658,32 @@ 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; - + // Keyboard-scanner support (physical scanners that act as keyboard wedges) + let keyboardScannerEnabled = false; + let keyboardScanBuffer = ''; + let keyboardLastKeyAt = 0; + const KEYBOARD_SCAN_INTERCHAR_MS = 100; // max time between keystrokes to consider them one scan + let editLibraryState = { + itemId: '', + 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 // ========================================================================= @@ -663,7 +712,7 @@ } } catch (error) { console.error('Error loading library items:', error); - document.getElementById('itemsTableBody').innerHTML = ''; + document.getElementById('itemsTableBody').innerHTML = ''; } finally { pagingState.loading = false; } @@ -755,10 +804,11 @@ 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 ` - +
TitelTitel ISBN/Code Typ AnzahlStatusAktionenStatusAktionen
Fehler beim Laden der Bibliothekselemente.
Fehler beim Laden der Bibliothekselemente.
${escapeHtml(item.Name || 'Untitled')}${escapeHtml(item.ISBN || item.Code_4 || item.Code4 || '-')}${escapeHtml(item.ISBN || '-')} ${getItemTypeLabel(item.ItemType || 'book')} ${item.Quantity || item.GroupedDisplayCount || 1} @@ -835,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); @@ -870,41 +920,48 @@ 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 + returnByCode(barcode); + return; + } + if (typeof currentCallback === "function") { currentCallback(barcode); } else { @@ -912,34 +969,87 @@ } }); + // ========================================================================= + // Keyboard scanner handling (physical scanners that send chars then Enter) + // ========================================================================= + function keyboardScanKeydownHandler(e) { + // Only active when explicitly enabled + if (!keyboardScannerEnabled) return; + + // Ignore if focus is in an input/textarea/contenteditable to avoid interfering with typing + const active = document.activeElement; + if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) return; + + const now = Date.now(); + + // If Enter/Return pressed -> finalize buffer + if (e.key === 'Enter') { + const code = keyboardScanBuffer.trim(); + keyboardScanBuffer = ''; + keyboardLastKeyAt = 0; + if (!code) return; + // If return-only mode is active, attempt direct return + const returnOnly = (document.getElementById('returnOnlyToggle') || {}).checked; + if (returnOnly) { + returnByCode(code); + return; + } + + // Process exactly like a camera scan + handleScanSuccess(code); + return; + } + + // Only accept common printable characters; ignore modifier keys + if (e.key.length === 1) { + // If time gap too big, start new buffer + if (keyboardLastKeyAt && (now - keyboardLastKeyAt) > KEYBOARD_SCAN_INTERCHAR_MS) { + keyboardScanBuffer = ''; + } + keyboardScanBuffer += e.key; + keyboardLastKeyAt = now; + // Prevent default so scanner input doesn't accidentally move focus or trigger shortcuts + e.preventDefault(); + } + } + function handleScanSuccess(decodedText) { const scannedCode = normalizeScannedCode(decodedText); if (!scannedCode) return; - + const now = Date.now(); if (scannedCode === lastScanValue && (now - lastScanAt) < 1500) { return; } lastScanValue = scannedCode; lastScanAt = now; - + const mode = (document.getElementById('scanModeSelect') || {}).value || 'card_only'; if (mode === 'card_only') { setActiveStudentCard(scannedCode); setScanStatus(`Ausweis gesetzt: ${activeStudentCardId}`, 'ok'); return; } - + processQuickToggleScan(scannedCode); } async function processQuickToggleScan(scannedCode) { + // 1. Prüfen, ob "Nur Rückgabe"-Modus aktiv ist + const returnOnly = (document.getElementById('returnOnlyToggle') || {}).checked; + if (returnOnly) { + await returnByCode(scannedCode); + return; + } + + // 2. Wenn kein Ausweis gesetzt ist, wird der Code als Ausweis interpretiert if (!activeStudentCardId) { setActiveStudentCard(scannedCode); setScanStatus(`Ausweis gesetzt: ${activeStudentCardId}`, 'ok'); return; } - + + // 3. Ausleihe/Rückgabe verarbeiten (wenn Ausweis vorhanden) try { setScanStatus('Verarbeite Mediencode...', 'warn'); const response = await fetch('/api/library_scan_action', { @@ -950,21 +1060,24 @@ item_code: scannedCode }) }); - + const result = await response.json(); if (!response.ok || !result.ok) { setScanStatus(result.message || 'Scan-Aktion fehlgeschlagen.', 'error'); return; } - + if (result.action === 'borrowed') { setScanStatus(`Ausgeliehen: ${result.item_name}`, 'ok'); + showSmallConfirm(`Ausgeliehen: ${result.item_name}`, 'ok'); } else if (result.action === 'returned') { setScanStatus(`Zurückgegeben: ${result.item_name}`, 'ok'); + showSmallConfirm(`Zurückgegeben: ${result.item_name}`, 'ok'); } else { setScanStatus(result.message || 'Aktion durchgeführt.', 'ok'); + showSmallConfirm(result.message || 'Aktion durchgeführt.', 'ok'); } - + await loadLibraryItems(); } catch (err) { console.error('Quick scan action failed:', err); @@ -972,6 +1085,33 @@ } } + async function returnByCode(code) { + if (!code) return; + setScanStatus('Verarbeite Rückgabe...', 'warn'); + try { + const resp = await fetch('/api/library_return_by_code', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ item_code: code }) + }); + const result = await resp.json(); + if (!resp.ok || !result.ok) { + setScanStatus(result.message || 'Rückgabe fehlgeschlagen.', 'error'); + showSmallConfirm(result.message || 'Rückgabe fehlgeschlagen.', 'error'); + return false; + } + setScanStatus(result.message || `Zurückgegeben: ${result.item_name || ''}`, 'ok'); + showSmallConfirm(result.message || `Zurückgegeben: ${result.item_name || ''}`, 'ok'); + await loadLibraryItems(); + return true; + } catch (err) { + console.error('Return by code failed:', err); + setScanStatus('Fehler bei Rückgabe.', 'error'); + showSmallConfirm('Fehler bei Rückgabe.', 'error'); + return false; + } + } + function scanIntoEditCode() { const scanReaderWrap = document.getElementById('scanReaderWrap'); const editCodeInput = document.getElementById('edit-code4'); @@ -979,15 +1119,15 @@ if (!scanReaderWrap || !editCodeInput || !scanEditBtn) { return; } - + if (scannerRunning && scanReaderWrap.style.display !== 'none') { stopScanner(); scanEditBtn.textContent = 'Barcode scannen'; return; } - + scanEditBtn.textContent = 'Scanner schließen'; - + startScanner(function(decodedText) { editCodeInput.value = decodedText; if(typeof validateCodeField === "function") { @@ -1006,16 +1146,16 @@ alert('Dieses Medium ist als defekt/zerstört markiert und kann nicht ausgeliehen werden.'); return; } - + const defaultCardId = activeStudentCardId || ''; const cardId = (window.prompt('Bitte Schülerausweis-ID eingeben:', defaultCardId) || '').trim().toUpperCase(); if (!cardId) { alert('Ausleihe abgebrochen: Für Bibliotheksmedien ist eine gültige Schülerausweis-ID erforderlich.'); return; } - + setActiveStudentCard(cardId); - + const durationInput = (window.prompt('Ausleihdauer in Tagen (optional):') || '').trim(); const maxAvailable = Math.max(1, parseInt(selectedItem?.AvailableGroupedCount || selectedItem?.Quantity || 1, 10) || 1); const countPrompt = (window.prompt(`Anzahl ausleihen? (Standard: 1, verfügbar: ${maxAvailable})`, '1') || '').trim(); @@ -1027,29 +1167,29 @@ alert(`Es sind nur ${maxAvailable} Exemplar(e) verfügbar.`); return; } - + const form = document.createElement('form'); form.method = 'POST'; form.action = `/ausleihen/${itemId}`; - + const csrfField = document.createElement('input'); csrfField.type = 'hidden'; csrfField.name = 'csrf_token'; csrfField.value = '{{ csrf_token }}'; form.appendChild(csrfField); - + const cardField = document.createElement('input'); cardField.type = 'hidden'; cardField.name = 'borrower_card_id'; cardField.value = cardId; form.appendChild(cardField); - + const returnTargetField = document.createElement('input'); returnTargetField.type = 'hidden'; returnTargetField.name = 'return_to'; returnTargetField.value = 'library'; form.appendChild(returnTargetField); - + if (durationInput) { const durationField = document.createElement('input'); durationField.type = 'hidden'; @@ -1057,13 +1197,13 @@ durationField.value = durationInput; form.appendChild(durationField); } - + const countField = document.createElement('input'); countField.type = 'hidden'; countField.name = 'exemplare_count'; countField.value = String(borrowCount || 1); form.appendChild(countField); - + document.body.appendChild(form); form.submit(); } @@ -1075,13 +1215,28 @@ el.classList.remove('ok', 'warn', 'error'); if (kind) el.classList.add(kind); } - + + function showSmallConfirm(message, kind='ok') { + // Append the small helper text in German + const helper = 'Sie können fortfahren. Dies ist nur eine kleine Benachrichtigung.'; + const el = document.createElement('div'); + el.className = `small-popup ${kind === 'error' ? 'error' : 'ok'}`; + el.innerHTML = `
${escapeHtml(String(message || ''))}
${helper}
×
`; + document.body.appendChild(el); + // close handler + el.querySelector('.close-x').addEventListener('click', () => { + if (el && el.parentNode) el.parentNode.removeChild(el); + }); + // auto remove after 3 seconds + setTimeout(() => { try { if (el && el.parentNode) el.parentNode.removeChild(el); } catch(e){} }, 3000); + } + function setActiveStudentCard(cardId) { activeStudentCardId = (cardId || '').trim().toUpperCase(); const input = document.getElementById('activeStudentCard'); if (input) input.value = activeStudentCardId; } - + function normalizeScannedCode(code) { return (code || '').trim(); } @@ -1097,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 = '

Loading details...

'; + detailContent.innerHTML = '

Lade Details...

'; 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 ` +
+ ${escapeHtml(item.Name || 'Medium')} +
+ ▶ +
+
`; + } else { + return `
VIDEO
`; + } + } else { + const imageSrcFinal = thumbnailInfo && thumbnailInfo.has_thumbnail + ? thumbnailInfo.thumbnail_url + : imageSrc; + + return `${escapeHtml(item.Name || 'Medium')}`; + } + }).join(''); + + mediaHtml = ``; + } + 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 = '

Sorry, we could not load the item details. Please try again later.

'; + detailContent.innerHTML = '

Entschuldigung, die Details konnten nicht geladen werden.

'; }); } @@ -1145,7 +1339,8 @@ const toggleBtn = document.getElementById('toggleScannerBtn'); const resetBtn = document.getElementById('resetCardBtn'); const modeSelect = document.getElementById('scanModeSelect'); - + const keyboardToggle = document.getElementById('keyboardScannerToggle'); + if (toggleBtn) { toggleBtn.addEventListener('click', async () => { if (scannerRunning) { @@ -1155,14 +1350,14 @@ } }); } - + if (resetBtn) { resetBtn.addEventListener('click', () => { setActiveStudentCard(''); setScanStatus('Ausweis zurückgesetzt. Bitte neu scannen.', 'warn'); }); } - + if (modeSelect) { modeSelect.addEventListener('change', () => { if (modeSelect.value === 'quick_toggle' && !activeStudentCardId) { @@ -1172,11 +1367,24 @@ } }); } + + if (keyboardToggle) { + keyboardToggle.addEventListener('change', () => { + keyboardScannerEnabled = !!keyboardToggle.checked; + if (keyboardScannerEnabled) { + document.addEventListener('keydown', keyboardScanKeydownHandler); + setScanStatus('Physischer Scanner aktiv (Schnellmodus empfohlen).', 'ok'); + } else { + document.removeEventListener('keydown', keyboardScanKeydownHandler); + setScanStatus('Physischer Scanner deaktiviert.', 'warn'); + } + }); + } } // Run when DOM structure is entirely ready - document.addEventListener('DOMContentLoaded', () => { - wireScannerUi(); // Setup scanner control buttons + document.addEventListener('DOMContentLoaded', async () => { + wireScannerUi(); // Setup scanner control buttons loadLibraryItems(); // Fetch your database items right away! // Safely connect standard Filters and Search inputs inside DOMContentLoaded @@ -1205,7 +1413,7 @@ document.getElementById('filterISBN').value = ''; document.getElementById('filterType').value = ''; document.getElementById('filterStatus').value = ''; - activeFilters = { isbn: '', type: '', status: '' }; + activeFilters = {isbn: '', type: '', status: ''}; applyFiltersAndSearch(true); }); } @@ -1226,44 +1434,107 @@ }); } - // Edit Modal Form processing + const manualReturnBtn = document.getElementById('manualReturnBtn'); + const manualItemCode = document.getElementById('manualItemCode'); + if (manualReturnBtn && manualItemCode) { + manualReturnBtn.addEventListener('click', async () => { + const code = (manualItemCode.value || '').trim(); + if (!code) { + alert('Bitte einen Mediencode eingeben.'); + return; + } + await returnByCode(code); + }); + } + const editForm = document.getElementById('editLibraryForm'); if (editForm) { - editForm.addEventListener('submit', async function(e) { - e.preventDefault(); + editForm.addEventListener('submit', async function (e) { + e.preventDefault(); const itemId = document.getElementById('editLibraryItemId').value; + const currentItem = libraryItems.find(i => i._id === itemId); + if (!currentItem) return; - const updatedData = { + const codeInputs = Array.from(document.querySelectorAll('#editLibraryCodesContainer input[data-item-id]')); + + // Daten aus dem Formular sammeln + const sharedPayload = { name: document.getElementById('editLibraryName').value, item_type: document.getElementById('editLibraryType').value, isbn: document.getElementById('editLibraryIsbn').value, - code_4: document.getElementById('editLibraryCode4').value, ort: document.getElementById('editLibraryLocation').value, - beschreibung: document.getElementById('editLibraryDescription').value + beschreibung: document.getElementById('editLibraryDescription').value, + ansch_jahr: currentItem.Anschaffungsjahr || '', + ansch_kost: currentItem.Anschaffungskosten || '', + reservierbar: currentItem.Reservierbar !== false, }; + const codeByItemId = new Map(codeInputs.map(input => [input.dataset.itemId, (input.value || '').trim()])); + const groupMembers = editLibraryState.groupMembers.length > 0 ? editLibraryState.groupMembers : [currentItem]; + const isGroupedEdit = Boolean(currentItem.SeriesGroupId) && groupMembers.length > 1; + + // API-Aufruf try { - const response = await fetch(`/api/library_item/${itemId}/update`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': '{{ csrf_token }}', - 'X-CSRF-Token': '{{ csrf_token }}' - }, - body: JSON.stringify(updatedData) - }); + if (isGroupedEdit) { + const payload = { + series_group_id: currentItem.SeriesGroupId, + ...sharedPayload, + items: groupMembers.map(member => ({ + id: member._id, + code_4: codeByItemId.get(member._id) || '' + })) + }; - const result = await response.json(); + const response = await fetch('/update_group', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': '{{ csrf_token }}', + 'X-CSRF-Token': '{{ csrf_token }}' + }, + body: JSON.stringify(payload) + }); - if (response.ok && result.ok) { - alert(result.message || 'Medium erfolgreich aktualisiert!'); - closeEditLibraryModal(); - - pagingState.loading = false; - loadLibraryItems(); + const result = await response.json(); + if (response.ok && result.success) { + alert(result.message || 'Gruppe erfolgreich aktualisiert!'); + closeEditLibraryModal(); + pagingState.loading = false; + await loadLibraryItems(); + } else { + alert(result.message || 'Fehler beim Speichern der Gruppenänderungen.'); + } } else { - alert(result.message || 'Fehler beim Speichern der Änderungen.'); + const primaryCodeInput = codeInputs[0]; + const payload = { + name: sharedPayload.name, + item_type: sharedPayload.item_type, + isbn: sharedPayload.isbn, + code_4: primaryCodeInput ? primaryCodeInput.value.trim() : (currentItem.Code_4 || currentItem.Code4 || '').trim(), + ort: sharedPayload.ort, + beschreibung: sharedPayload.beschreibung + }; + + const response = await fetch(`/api/library_item/${itemId}/update`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': '{{ csrf_token }}', + 'X-CSRF-Token': '{{ csrf_token }}' + }, + body: JSON.stringify(payload) + }); + + const result = await response.json(); + if (response.ok && result.ok) { + alert(result.message || 'Medium erfolgreich aktualisiert!'); + closeEditLibraryModal(); + pagingState.loading = false; + await loadLibraryItems(); + } else { + alert(result.message || 'Fehler beim Speichern der Änderungen.'); + } } } catch (error) { console.error('Update failed:', error); @@ -1273,195 +1544,76 @@ } }); - window.openEditLibraryItem = function(itemId) { - const item = libraryItems.find(i => i._id === itemId); - if (!item) return; - - // 1. Felder befüllen - document.getElementById('editLibraryItemId').value = item._id; - document.getElementById('editLibraryName').value = item.Name; - document.getElementById('editLibraryType').value = item.ItemType; - document.getElementById('editLibraryIsbn').value = item.ISBN || ''; - document.getElementById('editLibraryCode4').value = item.Code_4 || ''; - document.getElementById('editLibraryLocation').value = item.Ort; - document.getElementById('editLibraryDescription').value = item.Beschreibung; - - // 2. Gruppen-Logik - const warningDiv = document.getElementById('editLibraryGroupWarning'); - const codesContainer = document.getElementById('editLibraryAllCodes'); - - if (item.SeriesGroupId) { - // Filtern aus dem aktuell geladenen Array - let groupMembers = libraryItems.filter(i => i.SeriesGroupId === item.SeriesGroupId); - - // SCHLÜSSEL: Wenn die Anzahl der gefundenen Elemente nicht mit SeriesCount übereinstimmt, - // haben wir die Gruppe noch nicht vollständig geladen. - if (groupMembers.length < (item.SeriesCount || 0)) { - console.warn("Gruppe noch nicht vollständig geladen. Anzeige ggf. unvollständig."); - // Optional: Zeige einen Ladehinweis im Modal - codesContainer.textContent = "Lade restliche Gruppenmitglieder..."; - } else { - // Daten sind vollständig -> Anzeigen - const codeList = groupMembers - .sort((a, b) => (a.SeriesPosition || 0) - (b.SeriesPosition || 0)) - .map(m => m.Code_4 || "---") - .join(', '); - - codesContainer.textContent = codeList; + async function fetchLibraryGroupMembers(seriesGroupId) { + if (!seriesGroupId) return []; + try { + const response = await fetch(`/api/library_group/${encodeURIComponent(seriesGroupId)}`); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); } - document.getElementById('editLibraryGroupCount').textContent = groupMembers.length + " / " + (item.SeriesCount || "?"); - warningDiv.style.display = 'block'; - } else { - warningDiv.style.display = 'none'; + const payload = await response.json(); + return Array.isArray(payload.items) ? payload.items : []; + } catch (error) { + console.warn('Falling back to loaded library items for group editing:', error); + return (libraryItems || []).filter(item => item.SeriesGroupId === seriesGroupId); } - - document.getElementById('editLibraryModal').style.display = 'flex'; - }; - - function closeEditLibraryModal() { - document.getElementById('editLibraryModal').style.display = 'none'; } - /** - * Event-Listener für das Formular (Initialisierung) - */ - document.addEventListener('DOMContentLoaded', function() { - const editForm = document.getElementById('editLibraryForm'); - if (editForm) { - editForm.addEventListener('submit', async function(e) { - e.preventDefault(); + function renderLibraryGroupCodeFields(groupMembers, currentItemId) { + const codesContainer = document.getElementById('editLibraryCodesContainer'); + const groupWarning = document.getElementById('editLibraryGroupWarning'); + const groupCount = document.getElementById('editLibraryGroupCount'); + const groupHint = document.getElementById('editLibraryGroupHint'); - const itemId = document.getElementById('editLibraryItemId').value; - const currentItem = libraryItems.find(i => i._id === itemId); + if (!codesContainer) return; - if (!currentItem) return; + const items = Array.isArray(groupMembers) ? groupMembers.slice() : []; + items.sort((a, b) => (a.SeriesPosition || 0) - (b.SeriesPosition || 0) || String(a.Name || '').localeCompare(String(b.Name || ''))); - // 1. Alle Mitglieder der Gruppe finden, um die Code-Liste aufzubauen - const groupMembers = libraryItems.filter(i => i.SeriesGroupId === currentItem.SeriesGroupId); - const individualUpdates = groupMembers.map(member => ({ - id: member._id, - // Wenn dies das bearbeitete Item ist, nimm den neuen Code, sonst den alten - code_4: (member._id === itemId) ? document.getElementById('editLibraryCode4').value : member.Code_4 - })); - - // 2. Payload für das Backend bauen - const payload = { - series_group_id: currentItem.SeriesGroupId, - name: document.getElementById('editLibraryName').value, - ort: document.getElementById('editLibraryLocation').value, - beschreibung: document.getElementById('editLibraryDescription').value, - isbn: document.getElementById('editLibraryIsbn').value, - item_type: document.getElementById('editLibraryType').value, - items: individualUpdates - }; - - // 3. Request an die Gruppen-Update Route - try { - const response = await fetch('/update_group', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }); - - const result = await response.json(); - if (result.success) { - alert('Gruppe erfolgreich synchronisiert!'); - closeEditLibraryModal(); - await loadLibraryItems(); // Daten neu laden - // renderTable(); // Ggf. Tabelle neu rendern - } else { - await loadLibraryItems(); - closeEditLibraryModal(); - } - } catch (error) { - console.error('Update failed:', error); - alert('Netzwerkfehler.'); - } - }); + editLibraryState.groupMembers = items; + if (groupWarning) { + groupWarning.style.display = items.length > 1 ? 'block' : 'none'; } - }); + if (groupCount) { + const totalCount = items.length || 1; + const declaredCount = items[0]?.SeriesCount || totalCount; + groupCount.textContent = `${totalCount} / ${declaredCount}`; + } + if (groupHint) { + groupHint.textContent = items.length > 1 + ? 'Jeder Code gehört zu einem eigenen Exemplar. Änderungen werden für alle Codes gespeichert.' + : 'Einzelnes Exemplar. Der Code wird direkt gespeichert.'; + } + + if (!items.length) { + codesContainer.innerHTML = '
Keine Codes geladen.
'; + return; + } + + codesContainer.innerHTML = items.map((member, index) => { + const codeValue = member.Code_4 || member.Code4 || ''; + const labelParts = []; + if (member.SeriesPosition !== undefined && member.SeriesPosition !== null) { + labelParts.push(`Exemplar ${member.SeriesPosition}`); + } else { + labelParts.push(`Exemplar ${index + 1}`); + } + if (member._id === currentItemId) { + labelParts.push('aktuelles Medium'); + } + return ` +
+ + +
+ `; + }).join(''); + } + + function openEditLibraryItem(itemId) { + window.location.href = `/item_edit/${itemId}`; + } - - - {% endblock %} \ No newline at end of file diff --git a/Web/templates/main_admin.html b/Web/templates/main_admin.html index 88d73d9..534ac8a 100755 --- a/Web/templates/main_admin.html +++ b/Web/templates/main_admin.html @@ -1951,11 +1951,6 @@ display: none; } - /* Edit new location container */ - .edit-new-location-container { - display: none; - margin-top: 10px; - } /* Modal dialog styling */ .modal-dialog-white { @@ -1969,11 +1964,6 @@ margin-top: 10px; } - /* Element text colors for better visibility */ - .edit-button, .duplicate-button, .generate-qr-button { - color: var(--ui-title) !important; - } - /* Standardized button styles across the application */ .search-button, .scan-button, .filter-toggle, .clear-filter, .add-new-btn, .popup-close-button, .prev-image-button, .next-image-button, @@ -2474,187 +2464,6 @@ document.addEventListener('DOMContentLoaded', ()=>{ - - - {% if current_permissions.actions.get('can_edit', False) %} -
- -
- {% endif %} @@ -2889,58 +2698,6 @@ document.addEventListener('DOMContentLoaded', ()=>{ } }); - function scanIntoEditCode() { - const qrReader = document.getElementById('qr-reader'); - const editCodeInput = document.getElementById('edit-code4'); - const scanEditBtn = document.getElementById('scan-edit-code-btn'); - if (!qrReader || !editCodeInput || !scanEditBtn) { - return; - } - - // Toggle close if it's already running - if (isScanning && qrReader.style.display !== 'none') { - stopScanner(); - scanEditBtn.textContent = 'Barcode scannen'; - return; - } - - scanEditBtn.textContent = 'Scanner schließen'; - - // Start scanner with custom logic mapping to the Code input field - startScanner(function(decodedText) { - editCodeInput.value = decodedText; - validateCodeField(editCodeInput, document.getElementById('edit-item-id')?.value || null); - scanEditBtn.textContent = 'Barcode scannen'; - }); - } - - function scanIntoEditIsbn() { - const qrReader = document.getElementById('qr-reader'); - const editIsbnInput = document.getElementById('edit-isbn'); - const scanIsbnBtn = document.getElementById('scan-edit-isbn-btn'); - if (!qrReader || !editIsbnInput || !scanIsbnBtn) { - return; - } - - // Toggle close if it's already running - if (isScanning && qrReader.style.display !== 'none') { - stopScanner(); - scanIsbnBtn.textContent = 'ISBN scannen'; - return; - } - - scanIsbnBtn.textContent = 'Scanner schließen'; - - // Start scanner with custom logic mapping to the ISBN input field - startScanner(function(decodedText) { - editIsbnInput.value = decodedText; - scanIsbnBtn.textContent = 'ISBN scannen'; - if (typeof fetchBookInfo === 'function') { - fetchBookInfo('edit'); - } - }); - } - function rebuildFilter3Options() { if (!allItems) return; @@ -3324,21 +3081,11 @@ document.addEventListener('DOMContentLoaded', ()=>{ loadPredefinedFilterValues(1); loadPredefinedFilterValues(2); loadPredefinedFilterValues(3); - - // Set up edit form submission - setupEditFormSubmission(); + // Set up schedule form submission setupScheduleFormSubmission(); - - // Set up add new location buttons - const editAddLocationBtn = document.getElementById('edit-add-new-location-btn'); - if (editAddLocationBtn) { - editAddLocationBtn.addEventListener('click', function() { - const container = document.getElementById('edit-new-location-container'); - container.style.display = container.style.display === 'none' ? 'block' : 'none'; - }); - } + // Find and attach event listener to all logout links const logoutLinks = document.querySelectorAll('a[href*="logout"]'); @@ -3391,35 +3138,11 @@ document.addEventListener('DOMContentLoaded', ()=>{ // Close modals when clicking outside window.onclick = function(event) { const itemModal = document.getElementById('item-modal'); - const editModal = document.getElementById('edit-modal'); if (event.target === itemModal) { itemModal.style.display = 'none'; } - if (event.target === editModal) { - editModal.style.display = 'none'; - } }; - - // Set up code validation for edit form - const editCodeField = document.getElementById('edit-code4'); - if (editCodeField) { - editCodeField.addEventListener('blur', function() { - const itemIdField = document.getElementById('edit-item-id'); - const excludeId = itemIdField ? itemIdField.value : null; - validateCodeField(this, excludeId); - }); - } - - const scanEditCodeBtn = document.getElementById('scan-edit-code-btn'); - if (scanEditCodeBtn) { - scanEditCodeBtn.addEventListener('click', scanIntoEditCode); - } - - const scanEditIsbnBtn = document.getElementById('scan-edit-isbn-btn'); - if (scanEditIsbnBtn) { - scanEditIsbnBtn.addEventListener('click', scanIntoEditIsbn); - } }); // Function to load items from server @@ -4171,162 +3894,6 @@ document.addEventListener('DOMContentLoaded', ()=>{ }); } - function openEditModalForSelectedUnit(defaultItemId, selectId) { - let targetItemId = defaultItemId; - - try { - const selectedUnit = selectId ? document.getElementById(selectId) : null; - if (selectedUnit && selectedUnit.value) { - targetItemId = selectedUnit.value; - } - } catch (e) { - // Keep default item id as fallback. - } - - openEditModalFromServer(targetItemId); - } - - function closeEditModal() { - const editModal = document.getElementById('edit-modal'); - if (editModal) { - editModal.style.display = 'none'; - } - } - - function openEditModalFromServer(itemId) { - const editModal = document.getElementById('edit-modal'); - if (!editModal) { - console.error('Edit modal nicht gefunden'); - return; - } - - console.log('DEBUG: openEditModal called with itemId:', itemId); - - // Fetch the item data from the backend - fetch(`/get_item/${itemId}`) - .then(response => { - console.log('DEBUG: Response status:', response.status); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return response.json(); - }) - .then(data => { - console.log('DEBUG: Fetched data:', data); - // Backend returns the item directly or wrapped in error/success - const item = data.error ? null : (data.item || data); - - console.log('DEBUG: Parsed item:', item); - - if (!item || !item._id) { - console.error('DEBUG: Item not found or invalid'); - alert('Item nicht gefunden'); - return; - } - - // Fill in the form fields with the item data - document.getElementById('edit-item-id').value = item._id || ''; - document.getElementById('edit-name').value = item.Name || ''; - document.getElementById('edit-location').value = item.Ort || ''; - document.getElementById('edit-description').value = item.Beschreibung || ''; - document.getElementById('edit-year').value = item.Anschaffungsjahr || ''; - document.getElementById('edit-cost').value = item.Anschaffungskosten || ''; - document.getElementById('edit-code4').value = item.Code_4 || ''; - document.getElementById('edit-isbn').value = item.ISBN || ''; - document.getElementById('edit-reservierbar').checked = item.Reservierbar !== false; - - // Fill in filter 1 (Unterrichtsfach) - const filter1Array = Array.isArray(item.Filter) ? item.Filter : (item.Filter ? [item.Filter] : []); - document.getElementById('edit-filter1-1').value = filter1Array[0] || ''; - document.getElementById('edit-filter1-2').value = filter1Array[1] || ''; - document.getElementById('edit-filter1-3').value = filter1Array[2] || ''; - document.getElementById('edit-filter1-4').value = filter1Array[3] || ''; - - // Fill in filter 2 (Jahrgangsstufe) - const filter2Array = Array.isArray(item.Filter2) ? item.Filter2 : (item.Filter2 ? [item.Filter2] : []); - document.getElementById('edit-filter2-1').value = filter2Array[0] || ''; - document.getElementById('edit-filter2-2').value = filter2Array[1] || ''; - document.getElementById('edit-filter2-3').value = filter2Array[2] || ''; - document.getElementById('edit-filter2-4').value = filter2Array[3] || ''; - - // Fill in filter 3 (Schlagwort) - const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 : (item.Filter3 ? [item.Filter3] : []); - document.getElementById('edit-filter3-1').value = filter3Array[0] || ''; - document.getElementById('edit-filter3-2').value = filter3Array[1] || ''; - document.getElementById('edit-filter3-3').value = filter3Array[2] || ''; - document.getElementById('edit-filter3-4').value = filter3Array[3] || ''; - - // Display existing images - const existingImagesContainer = document.getElementById('edit-existing-images'); - const editForm = document.getElementById('edit-item-form'); - existingImagesContainer.innerHTML = ''; - if (editForm) { - editForm.querySelectorAll('input[name="existing_images"], input[name="removed_images"]').forEach(input => input.remove()); - } - if (item.Images && Array.isArray(item.Images)) { - item.Images.forEach((image, index) => { - const isVideo = isVideoFile(image); - const imageDiv = document.createElement('div'); - imageDiv.className = 'existing-image-item'; - imageDiv.style.marginBottom = '10px'; - - const thumbnailInfo = item.ThumbnailInfo && item.ThumbnailInfo[index]; - const imageSrc = thumbnailInfo && thumbnailInfo.has_preview ? - thumbnailInfo.preview_url : - (image.startsWith('/uploads/') || image.startsWith('http') ? - image : - `{{ url_for('uploaded_file', filename='') }}${image}`); - - const row = document.createElement('div'); - row.style.display = 'flex'; - row.style.gap = '8px'; - row.style.alignItems = 'center'; - - if (isVideo) { - const video = document.createElement('video'); - video.src = imageSrc; - video.style.maxWidth = '100px'; - video.style.maxHeight = '100px'; - video.style.objectFit = 'contain'; - video.controls = true; - row.appendChild(video); - } else { - const img = document.createElement('img'); - img.src = imageSrc; - img.style.maxWidth = '100px'; - img.style.maxHeight = '100px'; - img.style.objectFit = 'contain'; - img.alt = `Existierendes Bild ${index + 1}`; - row.appendChild(img); - } - - const deleteButton = document.createElement('button'); - deleteButton.type = 'button'; - deleteButton.className = 'delete-image-button'; - deleteButton.textContent = 'Löschen'; - deleteButton.addEventListener('click', () => removeExistingImage(image, deleteButton)); - row.appendChild(deleteButton); - - imageDiv.appendChild(row); - existingImagesContainer.appendChild(imageDiv); - - if (editForm) { - const hiddenInput = document.createElement('input'); - hiddenInput.type = 'hidden'; - hiddenInput.name = 'existing_images'; - hiddenInput.value = image; - editForm.appendChild(hiddenInput); - } - }); - } - - // Display the modal - editModal.style.display = 'block'; - }) - .catch(error => { - console.error('Fehler beim Laden des Items:', error); - alert('Fehler beim Laden des Items'); - }); - } - function escapeHtml(value) { return String(value ?? '').replace(/[&<>'"]/g, (char) => { const map = { @@ -4686,7 +4253,9 @@ document.addEventListener('DOMContentLoaded', ()=>{ `; - + + + modal.style.display = 'block'; const closeButton = modal.querySelector('.close-modal'); @@ -4923,6 +4492,13 @@ document.addEventListener('DOMContentLoaded', ()=>{ }); } + function openEditModalForSelectedUnit(itemId, selectId) { + const select = document.getElementById(selectId); + const targetId = (select && select.value) ? select.value : itemId; + // Leitet auf die Bearbeiten-Seite weiter und übergibt die aktuelle URL für den Redirect nach dem Speichern + window.location.href = `/item_edit/${targetId}`; + } + function changeModalImage(direction) { const currentIndex = window.currentModalImageIndex; const total = window.totalModalImages; @@ -5349,8 +4925,6 @@ document.addEventListener('DOMContentLoaded', ()=>{ }); } - // Load location options for edit modal - // Edit-related functions moved to edit_item_functions.html // Schedule modal functions function openScheduleModal(itemId) { @@ -5470,50 +5044,6 @@ document.addEventListener('DOMContentLoaded', ()=>{ } } - // Setup edit form submission - function setupEditFormSubmission() { - const editForm = document.getElementById('edit-item-form'); - if (editForm) { - editForm.addEventListener('submit', function(e) { - e.preventDefault(); - - const itemId = document.getElementById('edit-item-id').value; - const formData = new FormData(this); - - fetch(`/edit_item/${itemId}`, { - method: 'POST', - body: formData - }) - .then(response => { - if (response.ok) { - closeEditModal(); - // Reload items to show updated information - loadItems(); - // Show success message - const successMsg = document.createElement('div'); - successMsg.className = 'alert alert-success'; - successMsg.textContent = 'Item wurde erfolgreich aktualisiert!'; - successMsg.style.position = 'fixed'; - successMsg.style.top = '20px'; - successMsg.style.right = '20px'; - successMsg.style.zIndex = '9999'; - document.body.appendChild(successMsg); - setTimeout(() => { - if (successMsg.parentNode) { - successMsg.parentNode.removeChild(successMsg); - } - }, 3000); - } else { - alert('Fehler beim Aktualisieren des Items'); - } - }) - .catch(error => { - console.error('Error updating item:', error); - alert('Fehler beim Aktualisieren des Items'); - }); - }); - } - } // Duplication function function duplicateItem(itemId) { @@ -5632,10 +5162,62 @@ document.addEventListener('DOMContentLoaded', ()=>{ futureAppointments.sort((a, b) => new Date(a.date) - new Date(b.date)); return futureAppointments[0]; } - - -{% include "edit_item_functions.html" %} + // Load location options + function loadLocationOptions() { + fetch('/get_predefined_locations') + .then(response => response.json()) + .then(data => { + const ortSelect = document.getElementById('ort'); + if (ortSelect) { + // Clear existing options except the first one + while (ortSelect.children.length > 1) { + ortSelect.removeChild(ortSelect.lastChild); + } + + // Add new options - data.locations contains the array + data.locations.forEach(location => { + const option = document.createElement('option'); + option.value = location; + option.textContent = location; + ortSelect.appendChild(option); + }); + } + }) + .catch(error => { + console.error('Error loading location options:', error); + }); + } + + // Function to add new location + function addNewLocation() { + const newLocationInput = document.getElementById('new-location-input'); + const newLocation = newLocationInput.value.trim(); + + if (!newLocation) { + alert('Bitte geben Sie einen Ort ein.'); + return; + } + + // Add to dropdown + const ortSelect = document.getElementById('ort'); + const option = document.createElement('option'); + option.value = newLocation; + option.textContent = newLocation; + option.selected = true; + ortSelect.appendChild(option); + + // Hide the input container + document.getElementById('new-location-container').style.display = 'none'; + newLocationInput.value = ''; + } + + // Function to cancel adding new location + function cancelAddLocation() { + document.getElementById('new-location-container').style.display = 'none'; + document.getElementById('new-location-input').value = ''; + } + {% include "reset_item_functions.html" %} @@ -5864,16 +5446,4 @@ document.addEventListener('DOMContentLoaded', ()=>{ } } - -{% if open_item %} - -{% endif %} {% endblock %} diff --git a/Web/templates/manage_filters.html b/Web/templates/manage_filters.html index d87d475..1927361 100755 --- a/Web/templates/manage_filters.html +++ b/Web/templates/manage_filters.html @@ -19,7 +19,7 @@
-

{{ filter_names.get('1', 'Fach/Kategorie') }} (Filter 1)

+

{{ filter_names.get('1', 'Jahrgang') }} (Filter 1)

@@ -67,7 +67,7 @@
-

{{ filter_names.get('2', 'System/Bereich') }} (Filter 2)

+

{{ filter_names.get('2', 'Fach') }} (Filter 2)

@@ -110,54 +110,6 @@
- - -
-
-
-

{{ filter_names.get('3', 'Typ/Art') }} (Filter 3)

-
-
- -
- - -
- - -
Vorhandene Werte
- {% if filter3_values %} -
- {% for value in filter3_values %} -
-
- {{ value }} -
- -
- -
-
-
- -
- {% endfor %} -
- {% else %} -
Keine Werte definiert.
- {% endif %} -
-
-
diff --git a/Web/templates/register.html b/Web/templates/register.html index dcaf06f..5294c0d 100755 --- a/Web/templates/register.html +++ b/Web/templates/register.html @@ -6,7 +6,7 @@

Neuen Benutzer registrieren

-

Erstellen Sie ein neues Benutzerkonto und legen Sie Zugriffsrechte fest

+

Erstellen Sie ein neues Benutzerkonto oder importieren Sie mehrere Benutzer per CSV

@@ -23,7 +23,43 @@
+ +
+
+

Massenregistrierung via CSV

+

+ Laden Sie eine CSV-Datei hoch (Format: Vorname, Nachname). + Benutzernamen und sichere Passwörter werden serverseitig generiert. Nach dem Upload erhalten Sie direkt ein PDF mit Zugangsdaten (2 pro Seite zum Ausschneiden). +

+
+ +
+
+ + + + +
+ 📄 + +
+
+ +
+ +
+
+
+ +
+

Einzelnen Benutzer registrieren

@@ -32,11 +68,13 @@ 👤
+
👤
+
👤 @@ -44,7 +82,7 @@

Klarnamen werden nur zur Erzeugung des Benutzernamens als Kürzel (z.B. SimFri) verwendet; bei Kollision wird automatisch ein Buchstabe mehr genommen.

- +
@@ -57,20 +95,21 @@
  • Mindestens ein Sonderzeichen
  • - +
    🔒 - - +
    -
    +
    @@ -120,323 +159,6 @@
    - - + + {% endblock %} \ No newline at end of file diff --git a/Web/templates/upload_admin.html b/Web/templates/upload_admin.html index 441f8b8..f9380a0 100755 --- a/Web/templates/upload_admin.html +++ b/Web/templates/upload_admin.html @@ -761,6 +761,21 @@

    {{ page_title|default('Artikel hochladen') }}

    + + {% if show_library_features %} +
    + +
    + + + +
    + + Scannen oder manuell eingeben. Gültige ISBNs helfen beim Abruf von Buchdaten, andere Codes werden trotzdem akzeptiert. +
    +
    + {% endif %} +
    @@ -784,7 +799,44 @@
    - + +
    + + +
    + + + +
    + +
    + + +
    + + +
    + +
    + + + Bei Anzahl > 1 können hier individuelle Codes pro Item gesetzt werden. Der Scanner setzt immer zuerst den Basis-Code im Feld oben; weitere Einzelcodes werden hier angehängt. +
    + {% if show_library_features %}
    @@ -800,9 +852,9 @@ Wählen Sie einen Medientyp aus zur Klassifizierung.
    -

    Kategorie/Typ:

    +

    Kategorie/Typ/Fach:

    - + Geben Sie hier eine beliebige Kategorie ein zur freien Klassifizierung.
    @@ -902,71 +954,22 @@
    -
    - - -
    - -
    - -
    - - -
    - - -
    - -
    - - - Bei Anzahl > 1 können hier individuelle Codes pro Item gesetzt werden. Der Scanner setzt immer zuerst den Basis-Code im Feld oben; weitere Einzelcodes werden hier angehängt. -
    -
    +
    Erlaubte Formate: JPG, JPEG, PNG, GIF, MP4, MOV, AVI, MKV, WEBM, FLV, M4V, 3GP
    - - - {% if show_library_features %} -
    - -
    - - - -
    - - Scannen oder manuell eingeben. Gültige ISBNs helfen beim Abruf von Buchdaten, andere Codes werden trotzdem akzeptiert. -
    -
    - +
    Wenn deaktiviert, kann der Artikel nicht im Voraus reserviert werden (Sofort-Ausleihe bleibt möglich).
    - {% endif %} @@ -1023,6 +1026,90 @@