''' Copyright 2025-2026 AIIrondev Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag). See Legal/LICENSE for the full license text. Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited. For commercial licensing inquiries: https://github.com/AIIrondev ''' """ Inventarsystem - Flask Web Application This application provides an inventory management system with user authentication, item tracking, QR code generation, and borrowing/returning functionality. The system uses MongoDB for data storage and provides separate interfaces for regular users and administrators. Features: - User authentication (login/logout) - Item management (add, delete, view) - Borrowing and returning items - QR code generation for items - Administrative functions - History logging of item usage - Booking and reservation of items """ from flask import Flask, render_template, request, redirect, url_for, session, flash, send_from_directory, get_flashed_messages, jsonify, Response, make_response, send_file from werkzeug.utils import secure_filename import user as us import items as it import ausleihung as au import datetime from apscheduler.schedulers.background import BackgroundScheduler from pymongo import MongoClient from bson.objectid import ObjectId from urllib.parse import urlparse, urlunparse import requests import os import json import datetime import time import traceback import re import io import html # QR Code functionality deactivated # import qrcode # from qrcode.constants import ERROR_CORRECT_L import threading import sys import shutil import uuid from PIL import Image, ImageOps import mimetypes import subprocess # Set base directory and centralized settings BASE_DIR = os.path.dirname(os.path.abspath(__file__)) import settings as cfg app = Flask(__name__, static_folder='static') # Correctly set static folder app.secret_key = cfg.SECRET_KEY app.debug = cfg.DEBUG app.config['UPLOAD_FOLDER'] = cfg.UPLOAD_FOLDER app.config['THUMBNAIL_FOLDER'] = cfg.THUMBNAIL_FOLDER app.config['PREVIEW_FOLDER'] = cfg.PREVIEW_FOLDER app.config['ALLOWED_EXTENSIONS'] = set(cfg.ALLOWED_EXTENSIONS) # app.config['QR_CODE_FOLDER'] = cfg.QR_CODE_FOLDER # QR Code storage deactivated # Thumbnail sizes THUMBNAIL_SIZE = cfg.THUMBNAIL_SIZE PREVIEW_SIZE = cfg.PREVIEW_SIZE __version__ = cfg.APP_VERSION Host = cfg.HOST Port = cfg.PORT MONGODB_HOST = cfg.MONGODB_HOST MONGODB_PORT = cfg.MONGODB_PORT MONGODB_DB = cfg.MONGODB_DB SCHEDULER_INTERVAL = cfg.SCHEDULER_INTERVAL_MIN SSL_CERT = cfg.SSL_CERT SSL_KEY = cfg.SSL_KEY LIBRARY_ITEM_TYPES = ['book', 'cd', 'dvd', 'media'] INVOICE_CURRENCY = 'EUR' SCHOOL_PERIODS = cfg.SCHOOL_PERIODS # Apply the configuration for general use throughout the app APP_VERSION = __version__ def _parse_money_value(value): """Parse a user-facing money value into a float when possible.""" if value is None: return None if isinstance(value, (int, float)): return float(value) text = str(value).strip() if not text: return None cleaned = re.sub(r'[^0-9,\.\-]', '', text) if ',' in cleaned and '.' in cleaned: cleaned = cleaned.replace('.', '').replace(',', '.') else: cleaned = cleaned.replace(',', '.') try: return float(cleaned) except ValueError: return None def _format_money_value(value): """Format a money value in German notation for display.""" parsed_value = _parse_money_value(value) if parsed_value is None: return '-' formatted = f"{parsed_value:,.2f}".replace(',', 'X').replace('.', ',').replace('X', '.') return f"{formatted} {INVOICE_CURRENCY}" def _build_invoice_number(borrow_id, created_at): """Build a stable, human-readable invoice number.""" short_id = str(borrow_id)[-6:].upper() return f"INV-{created_at.strftime('%Y%m%d-%H%M%S')}-{short_id}" def _build_invoice_pdf(invoice_data): """Render a PDF invoice for a damaged borrowed item.""" from reportlab.lib.pagesizes import A4 from reportlab.lib.units import mm from reportlab.lib.colors import HexColor, black, white from reportlab.lib.utils import simpleSplit from reportlab.pdfgen import canvas pdf_buffer = io.BytesIO() c = canvas.Canvas(pdf_buffer, pagesize=A4) page_width, page_height = A4 margin_x = 20 * mm margin_top = 20 * mm usable_width = page_width - (2 * margin_x) current_y = page_height - margin_top dark_color = HexColor('#0F172A') accent_color = HexColor('#B91C1C') light_color = HexColor('#F8FAFC') border_color = HexColor('#CBD5E1') text_color = HexColor('#1E293B') muted_color = HexColor('#64748B') def draw_wrapped_lines(text, x_pos, y_pos, width, font_name='Helvetica', font_size=11, leading=14, color=text_color): if not text: return y_pos c.setFont(font_name, font_size) c.setFillColor(color) for line in simpleSplit(str(text), font_name, font_size, width): c.drawString(x_pos, y_pos, line) y_pos -= leading return y_pos def draw_label_value(label, value, x_pos, y_pos, label_width=45 * mm): c.setFont('Helvetica-Bold', 10) c.setFillColor(muted_color) c.drawString(x_pos, y_pos, label) c.setFont('Helvetica', 10) c.setFillColor(text_color) c.drawString(x_pos + label_width, y_pos, str(value or '-')) return y_pos - 7 * mm c.setFillColor(light_color) c.rect(0, 0, page_width, page_height, fill=1, stroke=0) c.setFillColor(dark_color) c.rect(0, page_height - 28 * mm, page_width, 28 * mm, fill=1, stroke=0) c.setFillColor(white) c.setFont('Helvetica-Bold', 20) c.drawString(margin_x, page_height - 16 * mm, 'RECHNUNG') c.setFont('Helvetica', 10) c.drawString(margin_x, page_height - 23 * mm, 'Inventarsystem - Schadensersatz für zerstörtes Ausleihobjekt') current_y = page_height - 40 * mm c.setStrokeColor(border_color) c.setLineWidth(1) c.line(margin_x, current_y, page_width - margin_x, current_y) current_y -= 12 * mm invoice_number = invoice_data.get('invoice_number', '-') created_at = invoice_data.get('created_at_display', '-') borrower = invoice_data.get('borrower', '-') item_name = invoice_data.get('item_name', '-') item_code = invoice_data.get('item_code', '-') item_id = invoice_data.get('item_id', '-') damage_reason = invoice_data.get('damage_reason', '-') amount_text = invoice_data.get('amount_text', '-') current_y = draw_label_value('Rechnungsnummer:', invoice_number, margin_x, current_y) current_y = draw_label_value('Datum:', created_at, margin_x, current_y) current_y = draw_label_value('Empfänger:', borrower, margin_x, current_y) current_y = draw_label_value('Ausleihe / Element:', item_name, margin_x, current_y) current_y = draw_label_value('Element-ID:', item_id, margin_x, current_y) current_y = draw_label_value('Code:', item_code, margin_x, current_y) current_y = current_y - 3 * mm c.setFillColor(dark_color) c.setFont('Helvetica-Bold', 12) c.drawString(margin_x, current_y, 'Schadensbeschreibung') current_y -= 6 * mm current_y = draw_wrapped_lines(damage_reason, margin_x, current_y, usable_width, font_size=10, leading=13, color=text_color) current_y -= 4 * mm c.setFillColor(dark_color) c.setFont('Helvetica-Bold', 12) c.drawString(margin_x, current_y, 'Rechnungsbetrag') current_y -= 8 * mm c.setFillColor(accent_color) c.setStrokeColor(accent_color) c.rect(margin_x, current_y - 12 * mm, usable_width, 16 * mm, fill=1, stroke=0) c.setFillColor(white) c.setFont('Helvetica-Bold', 16) c.drawString(margin_x + 5 * mm, current_y - 2 * mm, amount_text) current_y -= 20 * mm current_y = draw_wrapped_lines( 'Bitte begleichen Sie diesen Betrag zeitnah bei der Verwaltung. Der Betrag ergibt sich aus dem zerstörten Ausleihobjekt und der dokumentierten Schadensmeldung.', margin_x, current_y, usable_width, font_size=10, leading=13, color=muted_color, ) footer_y = 18 * mm c.setStrokeColor(border_color) c.setLineWidth(0.8) c.line(margin_x, footer_y + 8 * mm, page_width - margin_x, footer_y + 8 * mm) c.setFillColor(muted_color) c.setFont('Helvetica', 9) c.drawString(margin_x, footer_y, 'Inventarsystem - Rechnungserstellung') c.drawRightString(page_width - margin_x, footer_y, f'{amount_text}') c.save() pdf_buffer.seek(0) return pdf_buffer def _prepare_invoice_pdf_payload(invoice_data, borrow_doc=None, item_doc=None): """Normalize stored invoice data for robust PDF rendering.""" borrow_doc = borrow_doc or {} item_doc = item_doc or {} created_at_raw = invoice_data.get('created_at') created_at_display = '' if isinstance(created_at_raw, datetime.datetime): created_at_display = created_at_raw.strftime('%d.%m.%Y %H:%M') elif created_at_raw: created_at_display = str(created_at_raw) amount_value = _parse_money_value(invoice_data.get('amount')) if amount_value is None: amount_value = _parse_money_value(invoice_data.get('amount_text')) amount_text = invoice_data.get('amount_text') if not amount_text: amount_text = _format_money_value(amount_value) item_id = invoice_data.get('item_id') if not item_id and item_doc.get('_id'): item_id = str(item_doc.get('_id')) return { 'invoice_number': invoice_data.get('invoice_number') or _build_invoice_number(borrow_doc.get('_id', ''), datetime.datetime.now()), 'created_at': created_at_raw, 'created_at_display': created_at_display, 'borrower': invoice_data.get('borrower') or borrow_doc.get('User', '-'), 'item_name': invoice_data.get('item_name') or item_doc.get('Name', '-'), 'item_code': invoice_data.get('item_code') or item_doc.get('Code_4', '-'), 'item_id': str(item_id or '-'), 'damage_reason': invoice_data.get('damage_reason') or 'Keine Schadensbeschreibung hinterlegt.', 'amount': amount_value, 'amount_text': amount_text or '-', } @app.context_processor def inject_version(): """Inject global template variables.""" is_admin = False if 'username' in session: try: is_admin = us.check_admin(session['username']) except Exception: is_admin = False return { 'APP_VERSION': APP_VERSION, 'school_periods': SCHOOL_PERIODS, 'library_module_enabled': cfg.LIBRARY_MODULE_ENABLED, 'student_cards_module_enabled': cfg.STUDENT_CARDS_MODULE_ENABLED, 'is_admin': is_admin } # Create necessary directories at startup if not os.path.exists(app.config['UPLOAD_FOLDER']): os.makedirs(app.config['UPLOAD_FOLDER']) # QR Code directory creation deactivated # if not os.path.exists(app.config['QR_CODE_FOLDER']): # os.makedirs(app.config['QR_CODE_FOLDER']) # Create backup directories BACKUP_FOLDER = cfg.BACKUP_FOLDER if not os.path.exists(BACKUP_FOLDER): try: os.makedirs(BACKUP_FOLDER, exist_ok=True) except PermissionError: # Fallback: use a backup directory inside the application directory (writable) fallback_backup = os.path.join(BASE_DIR, 'backups') try: os.makedirs(fallback_backup, exist_ok=True) BACKUP_FOLDER = fallback_backup print(f"Warnung: Konnte BACKUP_FOLDER nicht erstellen. Fallback genutzt: {BACKUP_FOLDER}") except Exception as e: print(f"Fehler: Backup-Verzeichnis konnte nicht erstellt werden: {e}") def create_daily_backup(): """ Erstellt täglich ein Backup der Ausleihungsdatenbank """ try: print(f"[{datetime.datetime.now()}] Erstelle Backup der Ausleihungsdatenbank...") result = au.create_backup_database() if result: print(f"[{datetime.datetime.now()}] Backup erfolgreich erstellt") else: print(f"[{datetime.datetime.now()}] Fehler beim Erstellen des Backups") except Exception as e: print(f"[{datetime.datetime.now()}] Ausnahme beim Erstellen des Backups: {str(e)}") def update_appointment_statuses(): """ Aktualisiert automatisch die Status aller Terminplaner-Einträge. Diese Funktion wird jede Minute ausgeführt und überprüft: - Geplante Termine, die aktiviert werden sollten - Aktive Termine, die beendet werden sollten """ current_time = datetime.datetime.now() # Prepare logging early so it's available in exception paths log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'logs') os.makedirs(log_dir, exist_ok=True) log_file = os.path.join(log_dir, 'scheduler.log') try: with open(log_file, 'a', encoding='utf-8') as f: f.write(f"[{current_time}] Starte automatische Statusaktualisierung...\n") print(f"[{current_time}] Starte automatische Statusaktualisierung...") # Hole alle Termine mit Status 'planned' oder 'active' client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] ausleihungen = db['ausleihungen'] # Finde alle Termine, die status updates benötigen appointments_to_check = list(ausleihungen.find({ 'Status': {'$in': ['planned', 'active']} })) updated_count = 0 activated_count = 0 completed_count = 0 for appointment in appointments_to_check: old_status = appointment.get('Status') # Aktuellen Status bestimmen new_status = au.get_current_status(appointment, log_changes=True, user='scheduler') # Wenn sich der Status geändert hat, aktualisiere in der Datenbank if new_status != old_status: extra_fields = {} # --- Conflict resolver: planned → active transition --- # Check if the physical item is already borrowed by someone else if old_status == 'planned' and new_status == 'active': items_col = db['items'] item_id_str = appointment.get('Item') conflict_detected = False conflict_note = '' if item_id_str: try: item_doc = items_col.find_one( {'_id': ObjectId(item_id_str)}, {'Verfuegbar': 1, 'User': 1, 'Name': 1, 'Exemplare': 1} ) if item_doc: total_exemplare = int(item_doc.get('Exemplare', 1)) # Count how many active (non-planned) borrows currently hold this item active_borrows = ausleihungen.count_documents({ 'Item': item_id_str, 'Status': 'active', '_id': {'$ne': appointment['_id']} }) if active_borrows >= total_exemplare or item_doc.get('Verfuegbar') is False: conflict_detected = True borrower = item_doc.get('User', 'unbekannter Benutzer') item_name = item_doc.get('Name', item_id_str) conflict_note = ( f"Gegenstand '{item_name}' war beim Aktivieren von " f"'{appointment.get('User', '?')}' bereits ausgeliehen " f"von '{borrower}' (aktive Borrows: {active_borrows}/{total_exemplare})." ) extra_fields['ConflictDetected'] = True extra_fields['ConflictNote'] = conflict_note extra_fields['ConflictAt'] = current_time conflict_log = ( f" [KONFLIKT] Termin {appointment['_id']}: " f"planned → active, aber {conflict_note}" ) print(conflict_log) with open(log_file, 'a', encoding='utf-8') as f: f.write(f"[{current_time}] {conflict_log}\n") else: # No conflict — clear any previously stored conflict flag extra_fields['ConflictDetected'] = False extra_fields['ConflictNote'] = '' except Exception as conflict_err: print(f" [WARN] Konfliktprüfung fehlgeschlagen für Termin " f"{appointment['_id']}: {conflict_err}") result = ausleihungen.update_one( {'_id': appointment['_id']}, {'$set': { 'Status': new_status, 'LastUpdated': current_time, **extra_fields }} ) if result.modified_count > 0: updated_count += 1 if new_status == 'active': activated_count += 1 elif new_status == 'completed': completed_count += 1 log_msg = f" - Termin {appointment['_id']}: {old_status} → {new_status}" print(log_msg) with open(log_file, 'a', encoding='utf-8') as f: f.write(f"[{current_time}] {log_msg}\n") client.close() if updated_count > 0: result_msg = f"Statusaktualisierung abgeschlossen: {updated_count} Termine aktualisiert" detail_msg = f" - {activated_count} aktiviert, {completed_count} abgeschlossen" print(f"[{current_time}] {result_msg}") print(f"[{current_time}] {detail_msg}") with open(log_file, 'a', encoding='utf-8') as f: f.write(f"[{current_time}] {result_msg}\n") f.write(f"[{current_time}] {detail_msg}\n") else: result_msg = "Statusaktualisierung abgeschlossen: Keine Änderungen erforderlich" print(f"[{current_time}] {result_msg}") with open(log_file, 'a', encoding='utf-8') as f: f.write(f"[{current_time}] {result_msg}\n") except Exception as e: error_msg = f"Fehler bei der automatischen Statusaktualisierung: {str(e)}" print(f"[{datetime.datetime.now()}] {error_msg}") try: with open(log_file, 'a', encoding='utf-8') as f: f.write(f"[{datetime.datetime.now()}] {error_msg}\n") except Exception: pass import traceback traceback.print_exc() # Schedule jobs scheduler = BackgroundScheduler() if cfg.SCHEDULER_ENABLED: scheduler.add_job(func=create_daily_backup, trigger="interval", hours=cfg.BACKUP_INTERVAL_HOURS) scheduler.add_job(func=update_appointment_statuses, trigger="interval", minutes=cfg.SCHEDULER_INTERVAL_MIN) scheduler.start() # Register shutdown handler to stop scheduler when app is terminated import atexit atexit.register(lambda: scheduler.shutdown() if cfg.SCHEDULER_ENABLED else None) def allowed_file(filename, file_content=None, max_size_mb=cfg.MAX_UPLOAD_MB): """ Check if a file has an allowed extension and valid content. Args: filename (str): Name of the file to check file_content (FileStorage, optional): The actual file object to validate content max_size_mb (int, optional): Maximum allowed file size in MB Returns: tuple: (bool, str) - True if the file is valid, False otherwise along with an error message if not valid """ # Check file extension if '.' not in filename: return False, f"Datei '{filename}' hat keine Dateiendung. Erlaubte Formate: {', '.join(app.config['ALLOWED_EXTENSIONS'])}" extension = filename.rsplit('.', 1)[1].lower() allowed_extensions_lower = {ext.lower() for ext in app.config['ALLOWED_EXTENSIONS']} if extension not in allowed_extensions_lower: app.logger.warning(f"File extension not allowed: {extension} for file {filename}. Allowed: {allowed_extensions_lower}") return False, f"Datei '{filename}' hat ein nicht unterstütztes Format ({extension}). Erlaubte Formate: {', '.join(app.config['ALLOWED_EXTENSIONS'])}" # Check file size if content is provided if file_content is not None: # Check file size file_content.seek(0, os.SEEK_END) file_size = file_content.tell() / (1024 * 1024) # Size in MB file_content.seek(0) # Reset file pointer to beginning if file_size > max_size_mb: return False, f"Datei '{filename}' ist zu groß ({file_size:.1f} MB). Maximale Größe: {max_size_mb} MB." # Verify file content matches extension try: if extension in ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp']: try: # Special debug for PNG files if extension == 'png': app.logger.info(f"PNG DEBUG: Validating PNG file: {filename}, size: {file_size:.2f}MB") # Save first few bytes for magic number checking header_bytes = file_content.read(32) # Reading more bytes for deeper analysis file_content.seek(0) # Reset pointer # Check PNG magic number (first 8 bytes) png_signature = b'\x89PNG\r\n\x1a\n' is_valid_signature = header_bytes.startswith(png_signature) # Create a hex dump of the header for debugging hex_dump = ' '.join([f"{b:02x}" for b in header_bytes[:16]]) app.logger.info(f"PNG DEBUG: File {filename} - Header hex dump: {hex_dump}") app.logger.info(f"PNG DEBUG: Valid PNG signature: {is_valid_signature}, first bytes: {header_bytes[:8]!r}") # More detailed analysis of PNG chunks if is_valid_signature: try: # IHDR chunk should start at byte 8 if header_bytes[8:12] == b'IHDR': app.logger.info(f"PNG DEBUG: Found IHDR chunk at correct position") else: app.logger.warning(f"PNG DEBUG: IHDR chunk not found at expected position. Found: {header_bytes[8:12]!r}") except Exception as chunk_err: app.logger.error(f"PNG DEBUG: Error analyzing PNG chunks: {str(chunk_err)}") else: app.logger.error(f"PNG DEBUG: Invalid PNG signature for {filename}. Expected: {png_signature!r}") with Image.open(file_content) as img: # Verify it's a valid image by accessing its format and size img_format = img.format img_mode = img.mode img_size = img.size if extension == 'png': app.logger.info(f"PNG DEBUG: Successfully opened PNG - Format: {img_format}, Mode: {img_mode}, Size: {img_size[0]}x{img_size[1]}") # Add more PNG-specific checks app.logger.info(f"PNG DEBUG: Image info - Bands: {len(img.getbands())}, Bands: {img.getbands()}") # Check if there's transparency has_alpha = 'A' in img.getbands() or img.mode == 'P' and img.info.get('transparency') is not None app.logger.info(f"PNG DEBUG: Has transparency: {has_alpha}") if not img_format: if extension == 'png': app.logger.error(f"PNG DEBUG: Invalid format - got None for {filename}") return False, f"Datei '{filename}' scheint keine gültige Bilddatei zu sein." file_content.seek(0) # Reset file pointer after reading except Exception as e: error_msg = f"Error validating image content for {filename}: {str(e)}" app.logger.error(error_msg) if extension == 'png': app.logger.error(f"PNG DEBUG: Failed to process PNG file: {filename}") app.logger.error(f"PNG DEBUG: Error details: {str(e)}") app.logger.error(f"PNG DEBUG: Error type: {type(e).__name__}") # Get the full traceback as string and log it import io tb_output = io.StringIO() traceback.print_exc(file=tb_output) app.logger.error(f"PNG DEBUG: Full traceback for {filename}:\n{tb_output.getvalue()}") # Try to manually read the file data and check it try: file_content.seek(0) file_bytes = file_content.read(1024) # Read first KB file_content.seek(0) # Reset again hex_signature = ' '.join([f"{b:02x}" for b in file_bytes[:16]]) app.logger.error(f"PNG DEBUG: Raw file bytes (first 16): {hex_signature}") # Check for common corruption patterns if not file_bytes.startswith(png_signature): app.logger.error(f"PNG DEBUG: File doesn't start with PNG signature") if file_bytes.startswith(b'<'): app.logger.error(f"PNG DEBUG: File appears to be XML/HTML, not PNG") elif file_bytes.startswith(b'\xff\xd8'): app.logger.error(f"PNG DEBUG: File appears to be JPEG, not PNG") # Check file size again to make sure it's not empty file_content.seek(0, os.SEEK_END) actual_size = file_content.tell() file_content.seek(0) if actual_size < 100: # Very small for a PNG app.logger.error(f"PNG DEBUG: File is suspiciously small: {actual_size} bytes") except Exception as raw_err: app.logger.error(f"PNG DEBUG: Error during raw file analysis: {str(raw_err)}") traceback.print_exc() return False, f"Datei '{filename}' konnte nicht als Bild erkannt werden. Fehler: {str(e)}" # Add more content type validations as needed for other file types except Exception as e: app.logger.error(f"Error during content validation for {filename}: {str(e)}") file_content.seek(0) # Reset file pointer in case of error # Don't reject the file based on content validation failure alone return True, "" def strip_whitespace(value): """ Strip leading and trailing whitespace from a string or from each item in a list. Args: value: String or list of strings to strip Returns: String or list of strings with whitespace stripped """ if isinstance(value, str): return value.strip() elif isinstance(value, list): return [item.strip() if isinstance(item, str) else item for item in value] return value def sanitize_form_value(value): """ Strip whitespace and escape HTML for a string or each string item in a list. Args: value: String, list of strings, or None Returns: Sanitized string, list of sanitized strings, or the original value if unsupported """ value = strip_whitespace(value) if isinstance(value, str): return html.escape(value) if isinstance(value, list): return [html.escape(item) if isinstance(item, str) else item for item in value] return value def normalize_isbn(isbn_raw): """Return only digits/X from ISBN input in canonical uppercase form.""" if isbn_raw is None: return '' normalized = re.sub(r'[^0-9Xx]', '', str(isbn_raw)).upper() return normalized def is_valid_isbn10(isbn10): if not re.fullmatch(r'[0-9]{9}[0-9X]', isbn10): return False checksum = 0 for idx, char in enumerate(isbn10): value = 10 if char == 'X' else int(char) checksum += value * (10 - idx) return checksum % 11 == 0 def is_valid_isbn13(isbn13): if not re.fullmatch(r'[0-9]{13}', isbn13): return False checksum = 0 for idx, char in enumerate(isbn13[:12]): checksum += int(char) * (1 if idx % 2 == 0 else 3) check_digit = (10 - (checksum % 10)) % 10 return check_digit == int(isbn13[12]) def normalize_and_validate_isbn(isbn_raw): """Normalize ISBN and return a valid canonical ISBN-13/10 or empty string.""" isbn = normalize_isbn(isbn_raw) if len(isbn) == 13 and is_valid_isbn13(isbn): return isbn if len(isbn) == 10 and is_valid_isbn10(isbn): return isbn return '' @app.route('/uploads/') def uploaded_file(filename): """ Serve uploaded files from the uploads directory. Args: filename (str): Name of the file to serve Returns: flask.Response: The requested file or placeholder image if not found """ try: # Check production path first (deployed environment) prod_path = "/opt/Inventarsystem/Web/uploads" dev_path = app.config['UPLOAD_FOLDER'] if os.path.exists(os.path.join(prod_path, filename)): return send_from_directory(prod_path, filename) # Then check development path if os.path.exists(os.path.join(dev_path, filename)): return send_from_directory(dev_path, filename) # Use a placeholder image if file not found - first try SVG, then PNG svg_placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.svg') png_placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.png') if os.path.exists(svg_placeholder_path): return send_from_directory(app.static_folder, 'img/no-image.svg') elif os.path.exists(png_placeholder_path): return send_from_directory(app.static_folder, 'img/no-image.png') # Default placeholder from static folder return send_from_directory(app.static_folder, 'favicon.ico') except Exception as e: print(f"Error serving file {filename}: {str(e)}") return Response("Image not found", status=404) @app.route('/thumbnails/') def thumbnail_file(filename): """ Serve thumbnail files from the thumbnails directory. Args: filename (str): Name of the thumbnail file to serve Returns: flask.Response: The requested thumbnail file or placeholder image if not found """ try: # Check production path first prod_path = "/var/Inventarsystem/Web/thumbnails" dev_path = app.config['THUMBNAIL_FOLDER'] if os.path.exists(os.path.join(prod_path, filename)): return send_from_directory(prod_path, filename) if os.path.exists(os.path.join(dev_path, filename)): return send_from_directory(dev_path, filename) # Use a placeholder image if file not found - first try SVG, then PNG svg_placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.svg') png_placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.png') if os.path.exists(svg_placeholder_path): return send_from_directory(app.static_folder, 'img/no-image.svg') elif os.path.exists(png_placeholder_path): return send_from_directory(app.static_folder, 'img/no-image.png') else: return send_from_directory(app.static_folder, 'favicon.ico') except Exception as e: print(f"Error serving thumbnail {filename}: {str(e)}") return Response("Thumbnail not found", status=404) @app.route('/previews/') def preview_file(filename): """ Serve preview files from the previews directory. Args: filename (str): Name of the preview file to serve Returns: flask.Response: The requested preview file or placeholder image if not found """ try: # Check production path first prod_path = "/var/Inventarsystem/Web/previews" dev_path = app.config['PREVIEW_FOLDER'] if os.path.exists(os.path.join(prod_path, filename)): return send_from_directory(prod_path, filename) if os.path.exists(os.path.join(dev_path, filename)): return send_from_directory(dev_path, filename) # Use a placeholder image if file not found - first try SVG, then PNG svg_placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.svg') png_placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.png') if os.path.exists(svg_placeholder_path): return send_from_directory(app.static_folder, 'img/no-image.svg') elif os.path.exists(png_placeholder_path): return send_from_directory(app.static_folder, 'img/no-image.png') else: return send_from_directory(app.static_folder, 'favicon.ico') except Exception as e: print(f"Error serving preview {filename}: {str(e)}") return Response("Preview not found", status=404) # @app.route('/QRCodes/') # def qrcode_file(filename): # """ # Serve QR code files from the QRCodes directory. # # Args: # filename (str): Name of the QR code file to serve # # Returns: # flask.Response: The requested QR code file or placeholder image if not found # """ # try: # # Check production path first # prod_path = "/var/Inventarsystem/Web/QRCodes" # dev_path = app.config['QR_CODE_FOLDER'] # if os.path.exists(os.path.join(prod_path, filename)): # return send_from_directory(prod_path, filename) # if os.path.exists(os.path.join(dev_path, filename)): # return send_from_directory(dev_path, filename) # # # Use a placeholder image if file not found - first try SVG, then PNG # svg_placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.svg') # png_placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.png') # # if os.path.exists(svg_placeholder_path): # return send_from_directory(app.static_folder, 'img/no-image.svg') # elif os.path.exists(png_placeholder_path): # return send_from_directory(app.static_folder, 'img/no-image.png') # else: # return send_from_directory(app.static_folder, 'favicon.ico') # except Exception as e: # print(f"Error serving QR code {filename}: {str(e)}") # return Response("QR code not found", status=404) @app.route('/') def catch_all_files(filename): """ Fallback route to serve files from various directories. Tries to find the requested file in known directories. Args: filename (str): Name of the file to serve Returns: flask.Response: The requested file or placeholder image if not found """ try: # Check if the file exists in any of our directories possible_dirs = [ app.config['UPLOAD_FOLDER'], app.config['THUMBNAIL_FOLDER'], app.config['PREVIEW_FOLDER'], # app.config['QR_CODE_FOLDER'], # QR Code serving deactivated os.path.join(BASE_DIR, 'static') ] # Check development paths first for directory in possible_dirs: file_path = os.path.join(directory, filename) if os.path.isfile(file_path): return send_from_directory(directory, os.path.basename(filename)) # Check production paths if available if os.path.exists("/var/Inventarsystem/Web"): prod_dirs = [ "/var/Inventarsystem/Web/uploads", "/var/Inventarsystem/Web/thumbnails", "/var/Inventarsystem/Web/previews", # "/var/Inventarsystem/Web/QRCodes", # QR Code serving deactivated "/var/Inventarsystem/Web/static" ] for directory in prod_dirs: file_path = os.path.join(directory, filename) if os.path.isfile(file_path): return send_from_directory(directory, os.path.basename(filename)) # Check if this looks like an image request if any(filename.lower().endswith(ext) for ext in ['png', 'jpg', 'jpeg', 'gif', 'svg']): # Use a placeholder image if file not found - first try SVG, then PNG svg_placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.svg') png_placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.png') if os.path.exists(svg_placeholder_path): return send_from_directory(app.static_folder, 'img/no-image.svg') elif os.path.exists(png_placeholder_path): return send_from_directory(app.static_folder, 'img/no-image.png') else: return send_from_directory(app.static_folder, 'favicon.ico') # If we get here, the file wasn't found return Response(f"File {filename} not found", status=404) except Exception as e: print(f"Error in catch-all route for {filename}: {str(e)}") return Response(f"Error serving file: {str(e)}", status=500) @app.route('/test_connection', methods=['GET']) def test_connection(): """ Test API endpoint to verify the server is running. Returns: dict: Status information including version and status code """ return {'status': 'success', 'message': 'Connection successful', 'version': __version__, 'status_code': 200} @app.route('/user_status') def user_status(): """ API endpoint to get the current user's status (username, admin status). Used by JavaScript in templates to personalize the UI. Returns: JSON: User status information or error if not authenticated """ if 'username' in session: is_admin = us.check_admin(session['username']) return jsonify({ 'authenticated': True, 'username': session['username'], 'is_admin': is_admin }) else: return jsonify({ 'authenticated': False, 'error': 'Not logged in' }), 401 @app.route('/') def home(): """ Main route for the application homepage. Redirects to the appropriate view based on user role. Returns: flask.Response: Rendered template or redirect """ if 'username' not in session: flash('Bitte mit registriertem Konto anmelden!', 'error') return redirect(url_for('login')) elif not us.check_admin(session['username']): return render_template( 'main.html', username=session['username'], library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED, student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS, student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS ) else: return redirect(url_for('home_admin')) @app.route('/home_admin') def home_admin(): """ Admin homepage route. Only accessible by users with admin privileges. Returns: flask.Response: Rendered template 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') return redirect(url_for('login')) if not us.check_admin(session['username']): flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') return redirect(url_for('login')) return render_template( 'main_admin.html', username=session['username'], library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED, student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS, student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS ) @app.route('/tutorial') def tutorial_page(): """Guided onboarding page (2-5 minutes) for first-time users.""" if 'username' not in session: flash('Bitte mit registriertem Konto anmelden!', 'error') return redirect(url_for('login')) return render_template( 'tutorial.html', username=session['username'], is_admin=us.check_admin(session['username']), library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED, student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS, student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS ) @app.route('/library') def library_view(): """ Dedicated page for viewing library items (books, CDs, etc.). Only available when library module is enabled. Table-only view with customizable filter. Returns: flask.Response: Rendered template or redirect """ if 'username' not in session: flash('Bitte mit registriertem Konto anmelden!', 'error') return redirect(url_for('login')) if not cfg.LIBRARY_MODULE_ENABLED: flash('Bibliotheks-Modul ist deaktiviert.', 'error') return redirect(url_for('home')) return render_template( 'library_table.html', username=session['username'], library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, is_admin=us.check_admin(session['username']), student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED, student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS, student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS ) @app.route('/library_loans_admin') def library_loans_admin(): """Admin overview for library borrowings and damaged library items.""" 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') return redirect(url_for('login')) if not us.check_admin(session['username']): flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') return redirect(url_for('login')) if not cfg.LIBRARY_MODULE_ENABLED: flash('Bibliotheks-Modul ist deaktiviert.', 'error') return redirect(url_for('home_admin')) def fmt_dt(dt): try: return dt.strftime('%d.%m.%Y %H:%M') if dt else '' except Exception: return str(dt) if dt else '' def fmt_money(value): return _format_money_value(value) client = None try: client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] items_col = db['items'] ausleihungen_col = db['ausleihungen'] library_items = list(items_col.find( {'ItemType': {'$in': LIBRARY_ITEM_TYPES}}, {'Name': 1, 'Code_4': 1, 'Anschaffungskosten': 1, 'Condition': 1, 'HasDamage': 1, 'DamageReports': 1, 'Verfuegbar': 1, 'User': 1, 'ItemType': 1, 'Author': 1, 'ISBN': 1} )) item_map = {str(item['_id']): item for item in library_items if item.get('_id')} item_ids = list(item_map.keys()) active_records = [] if item_ids: active_records = list(ausleihungen_col.find( {'Item': {'$in': item_ids}, 'Status': {'$in': ['active', 'planned', 'completed']}}, {'User': 1, 'Item': 1, 'Status': 1, 'Start': 1, 'End': 1, 'Period': 1, 'Notes': 1, 'InvoiceData': 1} ).sort('Start', -1)) active_item_ids = set() loan_entries = [] for record in active_records: item_id = str(record.get('Item') or '') item_doc = item_map.get(item_id) if item_id and record.get('Status') == 'active': active_item_ids.add(item_id) if not item_doc: continue invoice_data = record.get('InvoiceData') or {} condition_value = str(item_doc.get('Condition', '')).strip().lower() item_has_damage = bool(item_doc.get('HasDamage')) or condition_value == 'destroyed' or bool(item_doc.get('DamageReports')) damage_reports = item_doc.get('DamageReports', []) or [] loan_entries.append({ 'id': str(record.get('_id')), 'item_id': item_id, 'item_name': item_doc.get('Name', ''), 'item_code': item_doc.get('Code_4', ''), 'item_author': item_doc.get('Author', ''), 'item_isbn': item_doc.get('ISBN', ''), 'user': record.get('User', ''), 'status': record.get('Status', ''), 'start': fmt_dt(record.get('Start')), 'end': fmt_dt(record.get('End')), 'period': record.get('Period', ''), 'notes': record.get('Notes', ''), 'invoice_number': invoice_data.get('invoice_number', ''), 'invoice_amount': fmt_money(invoice_data.get('amount')) if invoice_data.get('amount') is not None else fmt_money(item_doc.get('Anschaffungskosten')), 'invoice_paid': bool(invoice_data.get('paid', False)), 'invoice_paid_at': fmt_dt(invoice_data.get('paid_at')) if isinstance(invoice_data.get('paid_at'), datetime.datetime) else '', 'has_damage': item_has_damage, 'damage_count': len(damage_reports), 'damage_text': (damage_reports[0].get('description', '') if damage_reports else ''), }) damaged_items = [] for item_doc in library_items: item_id = str(item_doc.get('_id') or '') condition_value = str(item_doc.get('Condition', '')).strip().lower() damage_reports = item_doc.get('DamageReports', []) or [] item_has_damage = bool(item_doc.get('HasDamage')) or condition_value == 'destroyed' or bool(damage_reports) if not item_has_damage or item_id in active_item_ids: continue damaged_items.append({ 'id': item_id, 'name': item_doc.get('Name', ''), 'code': item_doc.get('Code_4', ''), 'author': item_doc.get('Author', ''), 'isbn': item_doc.get('ISBN', ''), 'condition': item_doc.get('Condition', ''), 'damage_count': len(damage_reports), 'damage_text': (damage_reports[0].get('description', '') if damage_reports else ''), 'available': bool(item_doc.get('Verfuegbar', False)), 'last_updated': fmt_dt(item_doc.get('LastUpdated')), }) return render_template( 'library_borrowings_admin.html', loan_entries=loan_entries, damaged_items=damaged_items, library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED, ) except Exception as e: app.logger.error(f"Error loading library loans admin view: {e}") flash('Fehler beim Laden der Bibliotheksverwaltung.', 'error') return redirect(url_for('home_admin')) finally: if client: client.close() @app.route('/api/library_items') def api_library_items(): """ API endpoint to fetch all library items (books, CDs, DVDs, media). Returns JSON array suitable for table view. """ if 'username' not in session: return jsonify([]), 401 try: client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] items_db = db['items'] ausleihungen_db = db['ausleihungen'] library_items = list(items_db.find({ 'ItemType': {'$in': ['book', 'cd', 'dvd', 'media']} })) item_ids = [str(item.get('_id')) for item in library_items if item.get('_id')] active_records = [] if item_ids: active_records = list(ausleihungen_db.find( {'Item': {'$in': item_ids}, 'Status': 'active'}, {'Item': 1, 'User': 1} )) active_item_ids = set() active_user_by_item = {} for rec in active_records: item_id = str(rec.get('Item') or '') if not item_id: continue active_item_ids.add(item_id) if item_id not in active_user_by_item: active_user_by_item[item_id] = rec.get('User', '') client.close() # Convert ObjectId to string for JSON serialization for item in library_items: item_id = str(item['_id']) item['_id'] = item_id condition_value = str(item.get('Condition', '')).strip().lower() has_damage = bool(item.get('HasDamage')) or condition_value == 'destroyed' has_active_borrow = item_id in active_item_ids if has_damage and not has_active_borrow: item['LibraryDisplayStatus'] = 'damaged' elif has_active_borrow or item.get('Verfuegbar') is False: item['LibraryDisplayStatus'] = 'borrowed' else: item['LibraryDisplayStatus'] = 'available' item['BorrowedBy'] = active_user_by_item.get(item_id) or item.get('User', '') return jsonify(library_items), 200 except Exception as e: print(f"Error fetching library items: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/library_scan_action', methods=['POST']) def api_library_scan_action(): """ Scan-based library workflow: - scan student card id - scan media code (ISBN/Code_4) - borrow if available, otherwise return (toggle behavior) """ if 'username' not in session: return jsonify({'ok': False, 'message': 'Nicht angemeldet.'}), 401 if not cfg.LIBRARY_MODULE_ENABLED: return jsonify({'ok': False, 'message': 'Bibliotheks-Modul ist deaktiviert.'}), 403 if not cfg.STUDENT_CARDS_MODULE_ENABLED: return jsonify({'ok': False, 'message': 'Schülerausweis-Modul ist deaktiviert.'}), 403 payload = request.get_json(silent=True) or {} student_card_id = us.normalize_student_card_id(payload.get('student_card_id') or payload.get('card_id')) item_code_raw = str(payload.get('item_code') or payload.get('code') or '').strip() duration_raw = str(payload.get('borrow_duration_days') or '').strip() if not student_card_id: return jsonify({'ok': False, 'message': 'Schülerausweis-ID fehlt.'}), 400 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: client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] student_cards_col = db['student_cards'] items_col = db['items'] ausleihungen_col = db['ausleihungen'] card_doc = student_cards_col.find_one({'AusweisId': student_card_id}) if not card_doc: return jsonify({'ok': False, 'message': 'Ungültige Schülerausweis-ID.'}), 404 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']) borrower_name = card_doc.get('SchülerName') or f"Ausweis {student_card_id}" now = datetime.datetime.now() if item_doc.get('Verfuegbar', True): borrow_duration_days = None if duration_raw: try: parsed_duration = int(duration_raw) if 1 <= parsed_duration <= cfg.STUDENT_MAX_BORROW_DAYS: borrow_duration_days = parsed_duration else: return jsonify({ 'ok': False, 'message': f'Ausleihdauer muss zwischen 1 und {cfg.STUDENT_MAX_BORROW_DAYS} Tagen liegen.' }), 400 except ValueError: return jsonify({'ok': False, 'message': 'Ungültige Ausleihdauer.'}), 400 else: try: card_default = int(card_doc.get('StandardAusleihdauer', cfg.STUDENT_DEFAULT_BORROW_DAYS)) except (TypeError, ValueError): card_default = cfg.STUDENT_DEFAULT_BORROW_DAYS borrow_duration_days = max(1, min(card_default, cfg.STUDENT_MAX_BORROW_DAYS)) end_date = now + datetime.timedelta(days=borrow_duration_days) if borrow_duration_days else None it.update_item_status(item_id, False, borrower_name) au.add_ausleihung(item_id, borrower_name, now, end_date=end_date) return jsonify({ 'ok': True, 'action': 'borrowed', 'item_id': item_id, 'item_name': item_doc.get('Name', ''), 'student_card_id': student_card_id, 'borrower': borrower_name, 'borrow_duration_days': borrow_duration_days, 'message': f"{item_doc.get('Name', 'Medium')} wurde ausgeliehen." }), 200 # Toggle back: item is currently borrowed -> return current_borrower = str(item_doc.get('User') or '').strip() if current_borrower and current_borrower != borrower_name and not us.check_admin(session['username']): return jsonify({ 'ok': False, 'message': f"Medium ist aktuell an '{current_borrower}' ausgeliehen und kann mit diesem Ausweis nicht zurückgegeben werden." }), 409 update_result = ausleihungen_col.update_many( {'Item': item_id, 'Status': 'active'}, {'$set': { 'Status': 'completed', 'End': now, 'LastUpdated': now }} ) it.update_item_status(item_id, True, borrower_name) return jsonify({ 'ok': True, 'action': 'returned', 'item_id': item_id, 'item_name': item_doc.get('Name', ''), 'student_card_id': student_card_id, 'borrower': borrower_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 scan action: {e}") return jsonify({'ok': False, 'message': 'Fehler beim Verarbeiten des Scan-Vorgangs.'}), 500 finally: if client: client.close() @app.route('/api/item_detail/') def api_item_detail(item_id): """ API endpoint to fetch detail view HTML for a library item. """ if 'username' not in session: return jsonify({'error': 'Unauthorized'}), 401 try: item = it.get_item(item_id) if not item: return jsonify({'error': 'Item not found'}), 404 client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] ausleihungen_col = db['ausleihungen'] active_borrow = ausleihungen_col.find_one( {'Item': str(item.get('_id')), 'Status': 'active'}, {'User': 1} ) client.close() condition_value = str(item.get('Condition', '')).strip().lower() has_damage = bool(item.get('HasDamage')) or condition_value == 'destroyed' if has_damage and not active_borrow: status_label = 'Defekt/Zerstört' elif item.get('Verfuegbar') is False or active_borrow: status_label = 'Ausgeliehen' else: status_label = 'Verfügbar' borrower_value = '' if active_borrow: borrower_value = active_borrow.get('User', '') elif item.get('User'): borrower_value = item.get('User') # Basic detail HTML detail_html = f"""

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

Autor/Künstler: {html.escape(item.get('Autor', item.get('Author', '-')))}

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

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

Status: {html.escape(status_label)}

{f'

Ausgeliehen von: {html.escape(str(borrower_value))}

' if borrower_value and status_label == 'Ausgeliehen' else ''} """ return detail_html, 200 except Exception as e: print(f"Error fetching item detail: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/library_item//update', methods=['POST']) def api_library_item_update(item_id): """Admin-only API to edit library item core fields from the library table view.""" if 'username' not in session: return jsonify({'ok': False, 'message': 'Nicht angemeldet.'}), 401 if not us.check_admin(session['username']): return jsonify({'ok': False, 'message': 'Administratorrechte erforderlich.'}), 403 if not cfg.LIBRARY_MODULE_ENABLED: return jsonify({'ok': False, 'message': 'Bibliotheks-Modul ist deaktiviert.'}), 403 payload = request.get_json(silent=True) or {} name = sanitize_form_value(payload.get('name')) description = sanitize_form_value(payload.get('beschreibung')) location = sanitize_form_value(payload.get('ort')) author = sanitize_form_value(payload.get('autor')) media_type = sanitize_form_value(payload.get('item_type')).lower() or 'book' code_4 = sanitize_form_value(payload.get('code_4')) isbn_raw = sanitize_form_value(payload.get('isbn')) if not name or not description or not location: return jsonify({'ok': False, 'message': 'Name, Ort und Beschreibung sind erforderlich.'}), 400 if media_type not in LIBRARY_ITEM_TYPES: return jsonify({'ok': False, 'message': 'Ungültiger Medientyp.'}), 400 normalized_isbn = '' if isbn_raw: normalized_isbn = normalize_and_validate_isbn(isbn_raw) if not normalized_isbn: return jsonify({'ok': False, 'message': 'Ungültige ISBN (nur ISBN-10/13).'}), 400 if code_4 and not it.is_code_unique(code_4, exclude_id=item_id): return jsonify({'ok': False, 'message': 'Der Code wird bereits verwendet.'}), 409 try: client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] items_col = db['items'] current = items_col.find_one({'_id': ObjectId(item_id)}) if not current: client.close() return jsonify({'ok': False, 'message': 'Element nicht gefunden.'}), 404 if current.get('ItemType') not in LIBRARY_ITEM_TYPES: client.close() return jsonify({'ok': False, 'message': 'Element ist kein Bibliotheksmedium.'}), 400 update_doc = { 'Name': name, 'Beschreibung': description, 'Ort': location, 'Autor': author, 'Code_4': code_4, 'ItemType': media_type, 'LastUpdated': datetime.datetime.now() } if normalized_isbn: update_doc['ISBN'] = normalized_isbn elif 'ISBN' in current: update_doc['ISBN'] = '' items_col.update_one({'_id': ObjectId(item_id)}, {'$set': update_doc}) client.close() return jsonify({'ok': True, 'message': 'Bibliotheksmedium aktualisiert.'}), 200 except Exception as e: app.logger.error(f"Error updating library item {item_id}: {e}") return jsonify({'ok': False, 'message': 'Fehler beim Aktualisieren des Bibliotheksmediums.'}), 500 @app.route('/upload_admin') def upload_admin(): """ Admin upload page route. Only accessible by users with admin privileges. Supports duplication by passing duplicate_from parameter. Returns: flask.Response: Rendered template 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') return redirect(url_for('login')) if not us.check_admin(session['username']): flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') return redirect(url_for('login')) # Check if this is a duplication request duplicate_from = request.args.get('duplicate_from') duplicate_flag = request.args.get('duplicate') # Check for sessionStorage-based duplication duplicate_data = None # Handle the old method (duplicate_from parameter with item ID) if duplicate_from: try: original_item = it.get_item(duplicate_from) if original_item: # Enhanced debug logging for images images = original_item.get('Images', []) print(f"DEBUG: Original item: {original_item.get('_id')} has these images: {images}") print(f"DEBUG: Images type: {type(images)}, count: {len(images) if isinstance(images, list) else 'not a list'}") duplicate_data = { 'name': original_item.get('Name', ''), 'description': original_item.get('Beschreibung', ''), 'location': original_item.get('Ort', ''), 'room': original_item.get('Raum', ''), 'category': original_item.get('Kategorie', ''), 'year': original_item.get('Anschaffungsjahr', ''), 'cost': original_item.get('Anschaffungskosten', ''), 'filter1': original_item.get('Filter1', ''), 'filter2': original_item.get('Filter2', ''), 'filter3': original_item.get('Filter3', ''), 'images': original_item.get('Images', []), 'original_id': duplicate_from } # Copy all filter fields (Filter1_1 through Filter3_5) for i in range(1, 4): # Filter1, Filter2, Filter3 for j in range(1, 6): # _1 through _5 filter_key = f'Filter{i}_{j}' if filter_key in original_item: duplicate_data[f'filter{i}_{j}'] = original_item[filter_key] flash('Element wird dupliziert. Bitte überprüfen Sie die Daten und passen Sie sie bei Bedarf an.', 'info') else: flash('Ursprungs-Element für Duplizierung nicht gefunden.', 'error') except Exception as e: print(f"Error loading item for duplication: {e}") flash('Fehler beim Laden der Duplizierungsdaten.', 'error') # Handle the new method (sessionStorage-based duplication) elif duplicate_flag == 'true': # No server-side processing needed - JavaScript will handle sessionStorage data # Just indicate that duplication mode is active flash('Element wird dupliziert. Die Daten werden aus dem Session-Speicher geladen.', 'info') return render_template( 'upload_admin.html', username=session['username'], duplicate_data=duplicate_data, library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED, show_library_features=False, upload_mode='item', page_title='Artikel hochladen', back_target='home_admin' ) @app.route('/library_admin') def library_admin(): """ Dedicated admin page for library/book uploads with ISBN scanning. Only accessible by admins and only when the library module is enabled. """ 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') return redirect(url_for('login')) if not us.check_admin(session['username']): flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') return redirect(url_for('login')) if not cfg.LIBRARY_MODULE_ENABLED: flash('Bibliotheks-Modul ist deaktiviert.', 'error') return redirect(url_for('home_admin')) duplicate_from = request.args.get('duplicate_from') duplicate_flag = request.args.get('duplicate') duplicate_data = None if duplicate_from: try: original_item = it.get_item(duplicate_from) if original_item: duplicate_data = { 'name': original_item.get('Name', ''), 'description': original_item.get('Beschreibung', ''), 'location': original_item.get('Ort', ''), 'room': original_item.get('Raum', ''), 'category': original_item.get('Kategorie', ''), 'year': original_item.get('Anschaffungsjahr', ''), 'cost': original_item.get('Anschaffungskosten', ''), 'filter1': original_item.get('Filter1', ''), 'filter2': original_item.get('Filter2', ''), 'filter3': original_item.get('Filter3', ''), 'images': original_item.get('Images', []), 'original_id': duplicate_from } for i in range(1, 4): for j in range(1, 6): filter_key = f'Filter{i}_{j}' if filter_key in original_item: duplicate_data[f'filter{i}_{j}'] = original_item[filter_key] flash('Buch wird dupliziert. Bitte überprüfen Sie die Daten und passen Sie sie bei Bedarf an.', 'info') else: flash('Ursprungs-Element für Duplizierung nicht gefunden.', 'error') except Exception as e: print(f"Error loading item for duplication: {e}") flash('Fehler beim Laden der Duplizierungsdaten.', 'error') elif duplicate_flag == 'true': flash('Buch wird dupliziert. Die Daten werden aus dem Session-Speicher geladen.', 'info') return render_template( 'upload_admin.html', username=session['username'], duplicate_data=duplicate_data, library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED, show_library_features=True, upload_mode='library', page_title='Bücher hochladen', back_target='home_admin' ) @app.route('/student_cards_admin', methods=['GET', 'POST']) def student_cards_admin(): """ Admin page for managing student library cards (Schülerausweise). Only accessible by admins and only when the student cards module is enabled. """ 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') return redirect(url_for('login')) if not us.check_admin(session['username']): flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') return redirect(url_for('login')) if not cfg.STUDENT_CARDS_MODULE_ENABLED: flash('Schülerausweis-Modul ist deaktiviert.', 'error') return redirect(url_for('home_admin')) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) db = client[cfg.MONGODB_DB] student_cards = db['student_cards'] edit_mode = False form_data = {} # Handle GET request to edit a card edit_id = request.args.get('edit') if edit_id: try: card = student_cards.find_one({'_id': ObjectId(edit_id)}) if card: edit_mode = True form_data = { 'card_id': str(card['_id']), 'ausweis_id': card.get('AusweisId', ''), 'student_name': card.get('SchülerName', ''), 'default_borrow_days': card.get('StandardAusleihdauer', 14), 'class_name': card.get('Klasse', ''), 'notes': card.get('Notizen', '') } except Exception as e: print(f"Error loading student card for edit: {e}") flash('Fehler beim Laden des Ausweises.', 'error') # Handle POST request (add or edit) if request.method == 'POST': action = request.form.get('action', 'add') ausweis_id = request.form.get('ausweis_id', '').strip().upper() student_name = request.form.get('student_name', '').strip() default_borrow_days = request.form.get('default_borrow_days', 14) class_name = request.form.get('class_name', '').strip() notes = request.form.get('notes', '').strip() if action == 'delete': try: card_id = request.form.get('card_id') student_cards.delete_one({'_id': ObjectId(card_id)}) flash('Ausweis wurde gelöscht.', 'success') except Exception as e: print(f"Error deleting student card: {e}") flash('Fehler beim Löschen des Ausweises.', 'error') elif action == 'edit': if not ausweis_id or not student_name: flash('Bitte Ausweis-ID und Schülername angeben.', 'error') else: try: card_id = request.form.get('card_id') # Check if new ID already exists (and it's not the same card) existing = student_cards.find_one({'AusweisId': ausweis_id, '_id': {'$ne': ObjectId(card_id)}}) if existing: flash('Diese Ausweis-ID existiert bereits.', 'error') else: student_cards.update_one( {'_id': ObjectId(card_id)}, {'$set': { 'AusweisId': ausweis_id, 'SchülerName': student_name, 'StandardAusleihdauer': int(default_borrow_days), 'Klasse': class_name, 'Notizen': notes, 'Aktualisiert': datetime.datetime.now() }} ) flash('Ausweis wurde aktualisiert.', 'success') return redirect(url_for('student_cards_admin')) except Exception as e: print(f"Error updating student card: {e}") flash('Fehler beim Aktualisieren des Ausweises.', 'error') elif action == 'add': if not ausweis_id or not student_name: flash('Bitte Ausweis-ID und Schülername angeben.', 'error') else: # Check if ID already exists existing = student_cards.find_one({'AusweisId': ausweis_id}) if existing: flash('Diese Ausweis-ID existiert bereits.', 'error') else: try: student_cards.insert_one({ 'AusweisId': ausweis_id, 'SchülerName': student_name, 'StandardAusleihdauer': int(default_borrow_days), 'Klasse': class_name, 'Notizen': notes, 'Erstellt': datetime.datetime.now() }) flash('Neuer Ausweis wurde hinzugefügt.', 'success') return redirect(url_for('student_cards_admin')) except Exception as e: print(f"Error adding student card: {e}") flash('Fehler beim Hinzufügen des Ausweises.', 'error') # Get all student cards all_cards = list(student_cards.find().sort('AusweisId', 1)) client.close() return render_template( 'student_cards_admin.html', username=session['username'], student_cards=all_cards, edit_mode=edit_mode, form_data=form_data, config={'default': cfg.STUDENT_DEFAULT_BORROW_DAYS}, library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED ) @app.route('/student_cards_print', methods=['GET']) def student_cards_print(): """ Generate a printable template for all student library cards (Schülerausweise). Only accessible by admins and only when the student cards module is enabled. """ 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') return redirect(url_for('login')) if not us.check_admin(session['username']): flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') return redirect(url_for('login')) if not cfg.STUDENT_CARDS_MODULE_ENABLED: flash('Schülerausweis-Modul ist deaktiviert.', 'error') return redirect(url_for('home_admin')) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) db = client[cfg.MONGODB_DB] student_cards = db['student_cards'] # Get all student cards sorted by ID all_cards = list(student_cards.find().sort('AusweisId', 1)) client.close() return render_template( 'student_cards_print.html', student_cards=all_cards, current_datetime=datetime.datetime.now() ) @app.route('/student_card_barcode_print', methods=['GET']) def student_card_barcode_print(): """ Generate a barcode-based print template for student library cards. Only accessible by admins and only when the student cards module is enabled. """ 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') return redirect(url_for('login')) if not us.check_admin(session['username']): flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') return redirect(url_for('login')) if not cfg.STUDENT_CARDS_MODULE_ENABLED: flash('Schülerausweis-Modul ist deaktiviert.', 'error') return redirect(url_for('home_admin')) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) db = client[cfg.MONGODB_DB] student_cards = db['student_cards'] # Get all student cards sorted by ID all_cards = list(student_cards.find().sort('AusweisId', 1)) client.close() return render_template( 'student_card_barcode_print.html', student_cards=all_cards, current_datetime=datetime.datetime.now(), download_link=url_for('student_card_barcode_download') ) @app.route('/student_card_barcode_download', methods=['GET']) def student_card_barcode_download(): """ Download PDF with all student card barcodes (simplified version). """ if 'username' not in session: return redirect(url_for('login')) if not us.check_admin(session['username']): flash('Zugriff verweigert.', 'error') return redirect(url_for('login')) if not cfg.STUDENT_CARDS_MODULE_ENABLED: flash('Schülerausweis-Modul ist deaktiviert.', 'error') return redirect(url_for('home_admin')) try: from reportlab.lib.pagesizes import A4 from reportlab.pdfgen import canvas from reportlab.lib.units import mm from reportlab.lib.colors import HexColor, white, black from io import BytesIO import barcode from barcode.writer import ImageWriter import tempfile import os client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) db = client[cfg.MONGODB_DB] student_cards = db['student_cards'] all_cards = list(student_cards.find().sort('AusweisId', 1)) client.close() pdf_buffer = BytesIO() c = canvas.Canvas(pdf_buffer, pagesize=A4) page_width, page_height = A4 margin = 10 * mm card_width = 88 * mm card_height = 56 * mm cols = 2 gap_x = 8 * mm gap_y = 8 * mm x_positions = [margin, margin + card_width + gap_x] y_start = page_height - margin y_pos = y_start col_idx = 0 # Professional color palette header_color = HexColor("#0F172A") # Sehr dunkles blau accent_color = HexColor("#2563EB") # Helles blau light_bg = HexColor("#F8FAFC") # Sehr heller grau card_bg_color = HexColor("#FFFFFF") # Weiß text_dark = HexColor("#1E293B") # Dunkler text text_gray = HexColor("#64748B") # Grauer text for i, card in enumerate(all_cards): if col_idx == 0 and i > 0: y_pos -= (card_height + gap_y) # New page if needed if y_pos - card_height < margin: c.showPage() y_pos = y_start col_idx = 0 x_pos = x_positions[col_idx] # Card shadow effect (unter border) c.setFillColor(HexColor("#E2E8F0")) c.rect(x_pos + 0.5*mm, y_pos - card_height - 0.5*mm, card_width, card_height, fill=1, stroke=0) # Card background c.setLineWidth(1) c.setFillColor(card_bg_color) c.setStrokeColor(HexColor("#CBD5E1")) c.rect(x_pos, y_pos - card_height, card_width, card_height, fill=1, stroke=1) # Left info section (38mm) info_width = 38 * mm c.setFillColor(light_bg) c.rect(x_pos, y_pos - card_height, info_width, card_height, fill=1, stroke=0) # Header bar c.setFillColor(header_color) c.rect(x_pos, y_pos - 10*mm, card_width, 10*mm, fill=1, stroke=0) # Header accent line c.setStrokeColor(accent_color) c.setLineWidth(2) c.line(x_pos, y_pos - 10*mm, x_pos + card_width, y_pos - 10*mm) # "SCHÜLERAUSWEIS" text in header c.setFont("Helvetica-Bold", 9) c.setFillColor(white) c.drawString(x_pos + 3*mm, y_pos - 6.5*mm, "SCHÜLERAUSWEIS") # Student name - large and bold c.setFillColor(text_dark) c.setFont("Helvetica-Bold", 10) name = card['SchülerName'][:20] c.drawString(x_pos + 3*mm, y_pos - 14*mm, name) # ID with label c.setFont("Helvetica", 8) c.setFillColor(text_gray) c.drawString(x_pos + 3*mm, y_pos - 18*mm, "Ausweis ID:") c.setFillColor(text_dark) c.setFont("Helvetica-Bold", 9) c.drawString(x_pos + 3*mm, y_pos - 21*mm, str(card['AusweisId'])) # Class with label if card.get('Klasse'): c.setFont("Helvetica", 8) c.setFillColor(text_gray) c.drawString(x_pos + 3*mm, y_pos - 25*mm, "Klasse:") c.setFillColor(text_dark) c.setFont("Helvetica-Bold", 9) c.drawString(x_pos + 3*mm, y_pos - 28*mm, card['Klasse']) # Right barcode section with border highlight barcode_x_start = x_pos + info_width + 1*mm c.setFillColor(accent_color) c.setLineWidth(0) c.rect(barcode_x_start - 1*mm, y_pos - card_height, card_width - info_width + 1*mm, 2*mm, fill=1, stroke=0) # Generate and add larger barcode try: temp_dir = tempfile.gettempdir() barcode_path = os.path.join(temp_dir, f"barcode_{card['AusweisId']}") # Generate barcode with higher module width for better scanning barcode_obj = barcode.get('code128', str(card['AusweisId']), writer=ImageWriter()) barcode_obj.save(barcode_path) barcode_file = f"{barcode_path}.png" if os.path.exists(barcode_file): # Larger barcode taking up most of right section barcode_width = (card_width - info_width - 4*mm) barcode_height = 16*mm barcode_y = y_pos - card_height + (card_height - barcode_height) / 2 + 2*mm c.drawImage(barcode_file, barcode_x_start + 1*mm, barcode_y, width=barcode_width, height=barcode_height, preserveAspectRatio=True) os.remove(barcode_file) else: raise Exception("Barcode file not created") except Exception as e: c.setFont("Helvetica", 7) c.setFillColor(HexColor("#DC2626")) c.drawString(barcode_x_start + 2*mm, y_pos - card_height + 20*mm, "⚠️ Barcode Error") col_idx += 1 if col_idx >= cols: col_idx = 0 c.save() pdf_buffer.seek(0) return send_file( pdf_buffer, mimetype='application/pdf', as_attachment=True, download_name=f'schuelerausweise_all_{datetime.datetime.now().strftime("%Y%m%d_%H%M%S")}.pdf' ) except Exception as e: flash(f'Fehler beim PDF-Download: {str(e)}', 'error') return redirect(url_for('student_cards_admin')) @app.route('/student_card_single_barcode_download/', methods=['GET']) def student_card_single_barcode_download(card_id): """ Download PDF with single student card barcode. """ if 'username' not in session: return redirect(url_for('login')) if not us.check_admin(session['username']): flash('Zugriff verweigert.', 'error') return redirect(url_for('login')) if not cfg.STUDENT_CARDS_MODULE_ENABLED: flash('Schülerausweis-Modul ist deaktiviert.', 'error') return redirect(url_for('home_admin')) try: from reportlab.lib.pagesizes import A4 from reportlab.pdfgen import canvas from reportlab.lib.units import mm from reportlab.lib.colors import HexColor, white, black from io import BytesIO from bson.objectid import ObjectId import barcode from barcode.writer import ImageWriter import tempfile import os client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) db = client[cfg.MONGODB_DB] student_cards = db['student_cards'] card = student_cards.find_one({'_id': ObjectId(card_id)}) client.close() if not card: flash('Ausweis nicht gefunden.', 'error') return redirect(url_for('student_cards_admin')) pdf_buffer = BytesIO() c = canvas.Canvas(pdf_buffer, pagesize=A4) page_width, page_height = A4 margin = 20 * mm card_width = 105 * mm card_height = 70 * mm x_pos = (page_width - card_width) / 2 y_pos = (page_height - card_height) / 2 # Professional color palette header_color = HexColor("#0F172A") # Sehr dunkles blau accent_color = HexColor("#2563EB") # Helles blau light_bg = HexColor("#F8FAFC") # Sehr heller grau card_bg_color = HexColor("#FFFFFF") # Weiß text_dark = HexColor("#1E293B") # Dunkler text text_gray = HexColor("#64748B") # Grauer text # Card shadow effect c.setFillColor(HexColor("#E2E8F0")) c.rect(x_pos + 1*mm, y_pos - card_height - 1*mm, card_width, card_height, fill=1, stroke=0) # Card background c.setLineWidth(1.5) c.setFillColor(card_bg_color) c.setStrokeColor(HexColor("#CBD5E1")) c.rect(x_pos, y_pos, card_width, card_height, fill=1, stroke=1) # Left info section (50mm) info_width = 50 * mm c.setFillColor(light_bg) c.rect(x_pos, y_pos - card_height, info_width, card_height, fill=1, stroke=0) # Vertical divider line c.setStrokeColor(accent_color) c.setLineWidth(3) c.line(x_pos + info_width, y_pos - card_height, x_pos + info_width, y_pos) # Header bar c.setFillColor(header_color) c.rect(x_pos, y_pos - 10*mm, card_width, 10*mm, fill=1, stroke=0) # Header accent line c.setStrokeColor(accent_color) c.setLineWidth(2.5) c.line(x_pos, y_pos - 10*mm, x_pos + card_width, y_pos - 10*mm) # "SCHÜLERAUSWEIS" text in header c.setFont("Helvetica-Bold", 11) c.setFillColor(white) c.drawString(x_pos + 4*mm, y_pos - 6.5*mm, "SCHÜLERAUSWEIS") # Student name - large and prominent c.setFillColor(text_dark) c.setFont("Helvetica-Bold", 12) name = card['SchülerName'][:25] c.drawString(x_pos + 4*mm, y_pos - 16*mm, name) # ID section with label c.setFont("Helvetica", 9) c.setFillColor(text_gray) c.drawString(x_pos + 4*mm, y_pos - 21*mm, "Ausweis-ID:") c.setFillColor(text_dark) c.setFont("Helvetica-Bold", 11) c.drawString(x_pos + 4*mm, y_pos - 25*mm, str(card['AusweisId'])) # Class section if card.get('Klasse'): c.setFont("Helvetica", 9) c.setFillColor(text_gray) c.drawString(x_pos + 4*mm, y_pos - 30*mm, "Klasse:") c.setFillColor(text_dark) c.setFont("Helvetica-Bold", 11) c.drawString(x_pos + 4*mm, y_pos - 34*mm, card['Klasse']) # Barcode section - right side with blue accent barcode_x_start = x_pos + info_width + 3*mm c.setFillColor(accent_color) c.setLineWidth(0) c.rect(x_pos + info_width, y_pos - card_height, card_width - info_width, 3*mm, fill=1, stroke=0) # Generate and add large barcode try: temp_dir = tempfile.gettempdir() barcode_path = os.path.join(temp_dir, f"barcode_{card['AusweisId']}") # Generate barcode with better sizing barcode_obj = barcode.get('code128', str(card['AusweisId']), writer=ImageWriter()) barcode_obj.save(barcode_path) barcode_file = f"{barcode_path}.png" if os.path.exists(barcode_file): # Large barcode on right side barcode_width = (card_width - info_width - 8*mm) barcode_height = 20*mm barcode_y_offset = (card_height - 10*mm - barcode_height) / 2 c.drawImage(barcode_file, barcode_x_start, y_pos - card_height + barcode_y_offset + 5*mm, width=barcode_width, height=barcode_height, preserveAspectRatio=True) os.remove(barcode_file) else: raise Exception("Barcode file not created") except Exception as e: c.setFont("Helvetica", 9) c.setFillColor(HexColor("#DC2626")) c.drawString(barcode_x_start + 2*mm, y_pos - card_height + 25*mm, "⚠️ Barcode Error") # Bottom info line c.setStrokeColor(HexColor("#CBD5E1")) c.setLineWidth(0.5) c.line(x_pos, y_pos - card_height + 3*mm, x_pos + card_width, y_pos - card_height + 3*mm) c.save() pdf_buffer.seek(0) return send_file( pdf_buffer, mimetype='application/pdf', as_attachment=True, download_name=f'ausweis_{card["AusweisId"]}.pdf' ) except Exception as e: flash(f'Fehler beim PDF-Download: {str(e)}', 'error') return redirect(url_for('student_cards_admin')) @app.route('/login', methods=['GET', 'POST']) def login(): """ User login route. Authenticates users and redirects to appropriate homepage based on role. Returns: flask.Response: Rendered template or redirect """ if 'username' in session: return redirect(url_for('home')) if request.method == 'POST': username = request.form['username'] password = request.form['password'] if not username or not password: flash('Bitte alle Felder ausfüllen', 'error') return redirect(url_for('login')) user = us.check_nm_pwd(username, password) if user: session['username'] = username is_admin_user = bool(user.get('Admin', False)) session['admin'] = is_admin_user session['is_admin'] = is_admin_user if is_admin_user: return redirect(url_for('home_admin')) else: return redirect(url_for('home')) else: flash('Ungültige Anmeldedaten', 'error') get_flashed_messages() return render_template('login.html') @app.route('/impressum') def impressum(): """ Impressum route. Returns: flask.Response: Redirect to impressum """ return render_template('impressum.html') @app.route('/license') def license(): """ License information route. Displays the Apache 2.0 license information. Returns: flask.Response: Rendered license template """ return render_template('license.html') @app.route('/change_password', methods=['GET', 'POST']) def change_password(): """ Change password route. Allows users to change their password if logged in. Returns: flask.Response: Rendered form or redirect after password change """ if 'username' not in session: flash('Sie müssen angemeldet sein, um Ihr Passwort zu ändern.', 'error') return redirect(url_for('login')) if request.method == 'POST': current_password = html.escape(request.form.get('current_password')) new_password = html.escape(request.form.get('new_password')) confirm_password = html.escape(request.form.get('confirm_password')) # Validate inputs if not all([current_password, new_password, confirm_password]): flash('Bitte füllen Sie alle Felder aus.', 'error') return render_template('change_password.html') if new_password != confirm_password: flash('Die neuen Passwörter stimmen nicht überein.', 'error') return render_template('change_password.html') # Verify current password user = us.check_nm_pwd(session['username'], current_password) if not user: flash('Das aktuelle Passwort ist nicht korrekt.', 'error') return render_template('change_password.html') # Check password strength if not us.check_password_strength(new_password): flash('Das neue Passwort ist zu schwach. Es sollte mindestens 6 Zeichen lang sein.', 'error') return render_template('change_password.html') # Update the password if us.update_password(session['username'], new_password): flash('Ihr Passwort wurde erfolgreich geändert.', 'success') return redirect(url_for('home')) else: flash('Fehler beim Ändern des Passworts. Bitte versuchen Sie es später erneut.', 'error') return render_template('change_password.html') @app.route('/logout') def logout(): """ User logout route. Removes user session data and redirects to login. Returns: flask.Response: Redirect to login page """ session.pop('username', None) session.pop('admin', None) session.pop('is_admin', None) return redirect(url_for('login')) @app.route('/get_items', methods=['GET']) def get_items(): """Return items plus merged favorites (session + DB) and per-item favorite flag.""" try: username = session.get('username') # Merge DB favorites into session if logged in if username: try: db_favs = set(us.get_favorites(username)) session_favs = set(session.get('favorites', [])) merged = list(db_favs.union(session_favs)) session['favorites'] = merged except Exception as fav_err: app.logger.warning(f"Could not merge DB favorites: {fav_err}") favorites = set(session.get('favorites', [])) client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] items_col = db['items'] items_cur = items_col.find({ 'IsGroupedSubItem': {'$ne': True}, 'ItemType': {'$nin': LIBRARY_ITEM_TYPES} }) items = [] for itm in items_cur: item_id_str = str(itm['_id']) grouped_children = list(items_col.find({ 'ParentItemId': item_id_str, 'IsGroupedSubItem': True })) grouped_count = 1 + len(grouped_children) grouped_units = [itm] + grouped_children available_units = [] grouped_all_codes = [] for unit in grouped_units: unit_code = unit.get('Code_4') if unit_code is not None and str(unit_code).strip() != '': grouped_all_codes.append(str(unit_code).strip()) if unit.get('Verfuegbar', True): unit_id = str(unit['_id']) if not isinstance(unit['_id'], str) else unit['_id'] code = unit.get('Code_4') or '-' available_units.append({ 'id': unit_id, 'code': code, 'label': f"{code} ({unit.get('Name', 'Item')})" }) itm['_id'] = item_id_str itm['GroupedDisplayCount'] = grouped_count itm['AvailableGroupedCount'] = len(available_units) itm['GroupedAvailableUnits'] = available_units itm['GroupedAllCodes'] = grouped_all_codes if grouped_count > 1: itm['Verfuegbar'] = len(available_units) > 0 itm['is_favorite'] = item_id_str in favorites items.append(itm) return jsonify({'items': items, 'favorites': list(favorites)}) except Exception as e: return jsonify({'items': [], 'error': str(e)}), 500 @app.route('/get_item/') def get_item_json(id): try: client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] item = db['items'].find_one({'_id': ObjectId(id)}) if not item: return jsonify({'error': 'not found'}), 404 item['_id'] = str(item['_id']) return jsonify(item) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/booking_conflicts') def api_booking_conflicts(): """ Returns all active bookings that have a detected conflict (i.e. the item was already borrowed when the planned booking activated). Regular users see only their own conflicts; admins see all. """ if 'username' not in session: return jsonify({'error': 'Not authenticated'}), 401 try: is_admin = us.check_admin(session['username']) client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] ausleihungen = db['ausleihungen'] query = {'ConflictDetected': True, 'Status': 'active'} if not is_admin: query['User'] = session['username'] conflicts = list(ausleihungen.find(query)) result = [] for c in conflicts: item_doc = it.get_item(c.get('Item')) item_name = item_doc.get('Name', c.get('Item', '?')) if item_doc else c.get('Item', '?') conflict_at = c.get('ConflictAt') if isinstance(conflict_at, datetime.datetime): conflict_at = conflict_at.strftime('%Y-%m-%d %H:%M') result.append({ 'id': str(c['_id']), 'Item': item_name, 'User': c.get('User', '?'), 'ConflictNote': c.get('ConflictNote', ''), 'ConflictAt': conflict_at, 'Status': c.get('Status', ''), }) client.close() return jsonify({'conflicts': result, 'count': len(result)}) except Exception as e: return jsonify({'error': str(e), 'conflicts': []}), 500 """Favorites management endpoints (persistent + session cache).""" def _ensure_session_favs(): if 'favorites' not in session: session['favorites'] = [] @app.route('/favorites', methods=['GET']) def list_favorites(): _ensure_session_favs() username = session.get('username') if username: try: db_favs = set(us.get_favorites(username)) merged = list(db_favs.union(set(session['favorites']))) session['favorites'] = merged except Exception as e: app.logger.warning(f"Listing favorites merge failed: {e}") return jsonify({'ok': True, 'favorites': session['favorites']}) @app.route('/favorites/', methods=['POST']) def add_fav(item_id): _ensure_session_favs() if item_id not in session['favorites']: session['favorites'].append(item_id) username = session.get('username') if username: try: us.add_favorite(username, item_id) except Exception as e: app.logger.warning(f"Persist add favorite failed: {e}") session.modified = True return jsonify({'ok': True, 'favorites': session['favorites']}) @app.route('/favorites/', methods=['DELETE']) def remove_fav(item_id): _ensure_session_favs() session['favorites'] = [f for f in session['favorites'] if f != item_id] username = session.get('username') if username: try: us.remove_favorite(username, item_id) except Exception as e: app.logger.warning(f"Persist remove favorite failed: {e}") session.modified = True return jsonify({'ok': True, 'favorites': session['favorites']}) @app.route('/favorites/toggle/', methods=['POST']) def toggle_fav(item_id): _ensure_session_favs() session_favs = [str(f) for f in session.get('favorites', [])] item_id = str(item_id) is_favorite = item_id in session_favs username = session.get('username') if is_favorite: session_favs = [f for f in session_favs if f != item_id] if username: try: us.remove_favorite(username, item_id) except Exception as e: app.logger.warning(f"Persist toggle(remove) favorite failed: {e}") else: session_favs.append(item_id) if username: try: us.add_favorite(username, item_id) except Exception as e: app.logger.warning(f"Persist toggle(add) favorite failed: {e}") # Normalize and de-duplicate while preserving order. deduped = [] seen = set() for fav in session_favs: if fav not in seen: seen.add(fav) deduped.append(fav) session['favorites'] = deduped session.modified = True return jsonify({ 'ok': True, 'is_favorite': item_id in deduped, 'favorites': deduped }) @app.route('/debug/favorites') def debug_favorites(): """Diagnostic endpoint: shows session favorites, DB favorites and merged output.""" username = session.get('username') session_favs = list(session.get('favorites', [])) db_favs = [] if username: try: db_favs = us.get_favorites(username) except Exception as e: return jsonify({'ok': False, 'error': f'db_error: {e}', 'session': session_favs}) merged = sorted(set(session_favs) | set(db_favs)) return jsonify({'ok': True, 'user': username, 'session': session_favs, 'db': db_favs, 'merged': merged}) @app.route('/upload_item', methods=['POST']) def upload_item(): """ Route for adding new items to the inventory. Handles file uploads and creates QR codes. Enhanced for mobile browser compatibility. Returns: flask.Response: Redirect to admin homepage or JSON response """ # Check if the user is authenticated if 'username' not in session: return jsonify({'success': False, 'message': 'Nicht angemeldet'}), 401 # Check if user is an admin username = session['username'] if not us.check_admin(username): return jsonify({'success': False, 'message': 'Administratorrechte erforderlich'}), 403 # 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: app.logger.info(f"Mobile upload from {request.headers.get('User-Agent', 'unknown')} by {username}") try: # Strip whitespace from all text fields name = sanitize_form_value(request.form['name']) ort = sanitize_form_value(request.form['ort']) beschreibung = sanitize_form_value(request.form['beschreibung']) # Check both possible image field names images = request.files.getlist('images') or request.files.getlist('new_images') filter_upload = sanitize_form_value(request.form.getlist('filter')) filter_upload2 = sanitize_form_value(request.form.getlist('filter2')) filter_upload3 = sanitize_form_value(request.form.getlist('filter3')) anschaffungs_jahr = sanitize_form_value(request.form.getlist('anschaffungsjahr')) anschaffungs_kosten = sanitize_form_value(request.form.getlist('anschaffungskosten')) code_4 = sanitize_form_value(request.form.getlist('code_4')) isbn_raw = sanitize_form_value(request.form.get('isbn', '')) upload_mode = sanitize_form_value(request.form.get('upload_mode', 'item')) individual_codes_raw = sanitize_form_value(request.form.get('individual_codes', '')) item_count_raw = sanitize_form_value(request.form.get('item_count', '1')) try: item_count = int(item_count_raw) if item_count_raw else 1 except (TypeError, ValueError): item_count = 1 item_count = max(1, min(item_count, 100)) # Optional list of per-item codes (one code per line) individual_codes = [] if individual_codes_raw: individual_codes = [c.strip() for c in str(individual_codes_raw).replace(',', '\n').splitlines() if c.strip()] # Check if this is a duplication is_duplicating = request.form.get('is_duplicating') == 'true' # Get duplicate_images if duplicating duplicate_images = request.form.getlist('duplicate_images') if is_duplicating else [] print(f"DEBUG: Duplicate images from form: {duplicate_images}, count: {len(duplicate_images)}") # Make sure duplicate_images is always a list, even if there's only one if is_duplicating and duplicate_images and not isinstance(duplicate_images, list): duplicate_images = [duplicate_images] # Log details about each image if is_duplicating and duplicate_images: for i, img in enumerate(duplicate_images): print(f"DEBUG: Duplicate image {i+1}/{len(duplicate_images)}: {img}") # Get book cover image if downloaded book_cover_image = request.form.get('book_cover_image') # Special handling for mobile browsers that might send data differently if is_mobile and 'mobile_data' in request.form: try: mobile_data = json.loads(request.form['mobile_data']) # Override values with mobile data if available if 'filters' in mobile_data: filter_upload = mobile_data.get('filters', []) if 'filters2' in mobile_data: filter_upload2 = mobile_data.get('filters2', []) if 'filters3' in mobile_data: filter_upload3 = mobile_data.get('filters3', []) if 'duplicate_images' in mobile_data and mobile_data['duplicate_images']: duplicate_images = mobile_data.get('duplicate_images', []) except json.JSONDecodeError as e: app.logger.error(f"Error parsing mobile data: {str(e)}") except Exception as e: error_msg = f"Fehler beim Verarbeiten der Formulardaten: {str(e)}" app.logger.error(error_msg) if is_mobile: return jsonify({'success': False, 'message': error_msg}), 400 else: flash('Fehler beim Verarbeiten der Formulardaten. Bitte versuchen Sie es erneut.', 'error') return redirect(url_for('home_admin')) # Validation if not name or not ort or not beschreibung: error_msg = 'Bitte füllen Sie alle erforderlichen Felder aus' if is_mobile: return jsonify({'success': False, 'message': error_msg}), 400 else: flash(error_msg, 'error') return redirect(url_for('home_admin')) item_isbn = '' item_type = 'general' if cfg.LIBRARY_MODULE_ENABLED: item_isbn = normalize_and_validate_isbn(isbn_raw) if isbn_raw and not item_isbn: error_msg = 'Ungültige ISBN. Bitte ISBN-10 oder ISBN-13 verwenden.' if is_mobile: return jsonify({'success': False, 'message': error_msg}), 400 flash(error_msg, 'error') return redirect(url_for('home_admin')) if item_isbn: item_type = 'book' if upload_mode == 'library': if not cfg.LIBRARY_MODULE_ENABLED: error_msg = 'Bibliotheks-Modul ist deaktiviert.' if is_mobile: return jsonify({'success': False, 'message': error_msg}), 400 flash(error_msg, 'error') return redirect(url_for('home_admin')) if not item_isbn: error_msg = 'Für Bücher ist eine gültige ISBN erforderlich.' if is_mobile: return jsonify({'success': False, 'message': error_msg}), 400 flash(error_msg, 'error') return redirect(url_for('library_admin')) item_type = 'book' # Only check for images if not duplicating and no duplicate images provided and no book cover # For library mode, skip this check as images come only from ISBN fetch if upload_mode != 'library' and not is_duplicating and not images and not duplicate_images and not book_cover_image: error_msg = 'Bitte laden Sie mindestens ein Bild hoch' if is_mobile: return jsonify({'success': False, 'message': error_msg}), 400 else: flash(error_msg, 'error') return redirect(url_for('home_admin')) # Check if base code is unique for single-item uploads if code_4 and item_count == 1 and not it.is_code_unique(code_4[0]): error_msg = 'Der Code wird bereits verwendet. Bitte wählen Sie einen anderen Code.' if is_mobile: return jsonify({'success': False, 'message': error_msg}), 400 else: flash(error_msg, 'error') return redirect(url_for('home_admin')) # Validate optional per-item codes if individual_codes: if len(individual_codes) > item_count: error_msg = f'Zu viele Einzelcodes angegeben ({len(individual_codes)}), erlaubt sind maximal {item_count}.' if is_mobile: return jsonify({'success': False, 'message': error_msg}), 400 flash(error_msg, 'error') return redirect(url_for('home_admin')) if len(set(individual_codes)) != len(individual_codes): error_msg = 'Doppelte Einzelcodes erkannt. Bitte alle Codes eindeutig eintragen.' if is_mobile: return jsonify({'success': False, 'message': error_msg}), 400 flash(error_msg, 'error') return redirect(url_for('home_admin')) for specific_code in individual_codes: if not it.is_code_unique(specific_code): error_msg = f'Der Einzelcode "{specific_code}" wird bereits verwendet.' if is_mobile: return jsonify({'success': False, 'message': error_msg}), 400 flash(error_msg, 'error') return redirect(url_for('home_admin')) def generate_unique_batch_code(base_code, position): """Generate a unique code for every item in a batch.""" if not base_code: return None candidate = base_code if position == 1 else f"{base_code}-{position}" if it.is_code_unique(candidate): return candidate suffix = 1 while suffix <= 1000: alternative = f"{candidate}-{suffix}" if it.is_code_unique(alternative): return alternative suffix += 1 return None # Process any new uploaded images with robust error handling image_filenames = [] processed_count = 0 error_count = 0 skipped_count = 0 # Create a structured log entry for upload session upload_session_id = str(uuid.uuid4())[:8] app.logger.info(f"Starting image upload session {upload_session_id} - Files: {len(images)}, User: {username}") # Ensure all required directories exist for directory in [app.config['UPLOAD_FOLDER']]: try: os.makedirs(directory, exist_ok=True) except Exception as e: app.logger.error(f"Failed to create directory {directory}: {str(e)}") # Process each image independently for index, image in enumerate(images): # In library mode, skip manual image uploads (use only book_cover_image from ISBN fetch) if upload_mode == 'library': app.logger.info(f"[Library Mode] Skipping manual image upload {index+1}/{len(images)}") skipped_count += 1 continue image_log_prefix = f"[Upload {upload_session_id}][Image {index+1}/{len(images)}]" if not image or not image.filename or image.filename == '': app.logger.warning(f"{image_log_prefix} Empty file or filename") skipped_count += 1 continue # Get file extension for special handling _, file_ext = os.path.splitext(image.filename.lower()) is_png = file_ext.lower() == '.png' if is_png: app.logger.info(f"PNG DEBUG: {image_log_prefix} Detected PNG file: {image.filename}") # Check file size image.seek(0, os.SEEK_END) file_size = image.tell() / (1024 * 1024) # Size in MB image.seek(0) # Reset file pointer app.logger.info(f"PNG DEBUG: {image_log_prefix} PNG file size: {file_size:.2f}MB") # Check first few bytes for PNG signature and analyze header header_bytes = image.read(64) # Read more for thorough analysis image.seek(0) # Reset pointer png_signature = b'\x89PNG\r\n\x1a\n' is_valid_signature = header_bytes.startswith(png_signature) # Create a hex dump of header for debugging hex_dump = ' '.join([f"{b:02x}" for b in header_bytes[:32]]) app.logger.info(f"PNG DEBUG: {image_log_prefix} PNG header hex: {hex_dump}") app.logger.info(f"PNG DEBUG: {image_log_prefix} PNG signature valid: {is_valid_signature}, bytes: {header_bytes[:8]!r}") # Analyze PNG chunks if signature is valid if is_valid_signature: try: # Look for IHDR chunk that should follow the signature if header_bytes[8:12] == b'IHDR': # Extract width and height from IHDR chunk (bytes 16-23) import struct width = struct.unpack('>I', header_bytes[16:20])[0] height = struct.unpack('>I', header_bytes[20:24])[0] bit_depth = header_bytes[24] color_type = header_bytes[25] app.logger.info(f"PNG DEBUG: {image_log_prefix} PNG dimensions from header: {width}x{height}, bit depth: {bit_depth}, color type: {color_type}") else: app.logger.warning(f"PNG DEBUG: {image_log_prefix} Expected IHDR chunk not found. Found: {header_bytes[8:12]!r}") except Exception as chunk_err: app.logger.error(f"PNG DEBUG: {image_log_prefix} Error analyzing PNG chunks: {str(chunk_err)}") else: app.logger.error(f"PNG DEBUG: {image_log_prefix} Invalid PNG signature!") app.logger.info(f"{image_log_prefix} Processing: {image.filename}") try: # Comprehensive file validation with detailed logging is_allowed, error_message = allowed_file(image.filename, image, max_size_mb=cfg.IMAGE_MAX_UPLOAD_MB) if not is_allowed: app.logger.warning(f"{image_log_prefix} Validation failed: {error_message}") if is_png: app.logger.error(f"PNG DEBUG: {image_log_prefix} PNG validation failed: {error_message}") skipped_count += 1 if not is_mobile: flash(error_message, 'error') continue # Get the file extension for content type determination secure_name = secure_filename(image.filename) _, ext_part = os.path.splitext(secure_name) is_png = ext_part.lower() == '.png' # Generate a completely unique filename using UUID unique_id = str(uuid.uuid4()) timestamp = time.strftime("%Y%m%d%H%M%S") # New filename format with UUID to ensure uniqueness saved_filename = f"{unique_id}_{timestamp}{ext_part}" app.logger.info(f"{image_log_prefix} Assigned unique filename: {saved_filename}") if is_png: app.logger.info(f"PNG DEBUG: {image_log_prefix} Creating PNG with filename: {saved_filename}") # For iOS devices, we need special handling for the file save if is_ios: app.logger.info(f"{image_log_prefix} Using iOS-specific file handling") # Save to a temporary file first to avoid iOS stream issues temp_path = os.path.join(app.config['UPLOAD_FOLDER'], f"temp_{saved_filename}") try: if is_png: app.logger.info(f"PNG DEBUG: {image_log_prefix} Using iOS PNG save method") # Before saving, verify the file content again try: image.seek(0) pre_save_data = image.read(16) image.seek(0) pre_save_hex = ' '.join([f"{b:02x}" for b in pre_save_data]) app.logger.info(f"PNG DEBUG: {image_log_prefix} Pre-save data: {pre_save_hex}") except Exception as pre_err: app.logger.error(f"PNG DEBUG: {image_log_prefix} Error checking pre-save data: {str(pre_err)}") # For PNGs, try a direct binary save first if is_png: try: app.logger.info(f"PNG DEBUG: {image_log_prefix} Attempting binary save for iOS PNG") image.seek(0) png_data = image.read() with open(temp_path, 'wb') as f: f.write(png_data) app.logger.info(f"PNG DEBUG: {image_log_prefix} Binary write complete, size: {len(png_data)} bytes") except Exception as bin_err: app.logger.error(f"PNG DEBUG: {image_log_prefix} Binary write failed: {str(bin_err)}") # Fall back to normal save image.seek(0) image.save(temp_path) else: image.save(temp_path) # Validate the saved file if os.path.exists(temp_path) and os.path.getsize(temp_path) > 0: if is_png: file_size = os.path.getsize(temp_path) app.logger.info(f"PNG DEBUG: {image_log_prefix} Temp PNG file saved successfully: {file_size/1024:.1f}KB") # Verify it's a valid PNG try: with open(temp_path, 'rb') as f: png_header = f.read(16) png_signature = b'\x89PNG\r\n\x1a\n' is_valid = png_header.startswith(png_signature) header_hex = ' '.join([f"{b:02x}" for b in png_header]) app.logger.info(f"PNG DEBUG: {image_log_prefix} Saved file header: {header_hex}") if not is_valid: app.logger.error(f"PNG DEBUG: {image_log_prefix} Invalid PNG signature in saved file!") else: app.logger.info(f"PNG DEBUG: {image_log_prefix} Valid PNG signature confirmed in saved file") # Try opening with PIL to confirm it's valid try: with Image.open(temp_path) as img: app.logger.info(f"PNG DEBUG: {image_log_prefix} Saved PNG validates with PIL: {img.format} {img.size}") except Exception as pil_err: app.logger.error(f"PNG DEBUG: {image_log_prefix} Saved PNG fails PIL validation: {str(pil_err)}") except Exception as verify_err: app.logger.error(f"PNG DEBUG: {image_log_prefix} Error verifying PNG: {str(verify_err)}") # Rename to the final filename final_path = os.path.join(app.config['UPLOAD_FOLDER'], saved_filename) os.rename(temp_path, final_path) app.logger.info(f"{image_log_prefix} Successfully saved via iOS handler: {os.path.getsize(final_path)/1024:.1f}KB") else: if is_png: app.logger.error(f"PNG DEBUG: {image_log_prefix} Failed to save temp PNG file (zero size or missing)") raise Exception("Failed to save image file (zero size or missing)") except Exception as e: app.logger.error(f"{image_log_prefix} iOS save failed: {str(e)}") if is_png: app.logger.error(f"PNG DEBUG: {image_log_prefix} iOS PNG save failed: {str(e)}") app.logger.error(f"PNG DEBUG: {image_log_prefix} Error type: {type(e).__name__}") # Log full traceback for PNG errors import io tb_output = io.StringIO() traceback.print_exc(file=tb_output) app.logger.error(f"PNG DEBUG: {image_log_prefix} Full traceback:\n{tb_output.getvalue()}") # Try regular save as fallback try: image.seek(0) # Reset file pointer if is_png: app.logger.info(f"PNG DEBUG: {image_log_prefix} Attempting fallback PNG save method") image.save(os.path.join(app.config['UPLOAD_FOLDER'], saved_filename)) app.logger.info(f"{image_log_prefix} Fallback save successful") if is_png: app.logger.info(f"PNG DEBUG: {image_log_prefix} Fallback PNG save successful") except Exception as fallback_err: app.logger.error(f"{image_log_prefix} Fallback save also failed: {str(fallback_err)}") if is_png: app.logger.error(f"PNG DEBUG: {image_log_prefix} Fallback PNG save also failed: {str(fallback_err)}") app.logger.error(f"PNG DEBUG: {image_log_prefix} Error type: {type(fallback_err).__name__}") traceback.print_exc() error_count += 1 continue else: # Regular file save for non-iOS devices try: if is_png: app.logger.info(f"PNG DEBUG: {image_log_prefix} Using standard PNG save method") # Check file content before saving try: image.seek(0) pre_save_data = image.read(16) image.seek(0) pre_save_hex = ' '.join([f"{b:02x}" for b in pre_save_data]) app.logger.info(f"PNG DEBUG: {image_log_prefix} Pre-save data: {pre_save_hex}") # Check if it's really a PNG png_signature = b'\x89PNG\r\n\x1a\n' if not pre_save_data.startswith(png_signature): app.logger.error(f"PNG DEBUG: {image_log_prefix} File does not have valid PNG signature before save!") except Exception as pre_err: app.logger.error(f"PNG DEBUG: {image_log_prefix} Error checking pre-save data: {str(pre_err)}") # Try an alternative saving method for PNGs with detailed error tracking try: # Read the image data directly image.seek(0) image_data = image.read() app.logger.info(f"PNG DEBUG: {image_log_prefix} Read {len(image_data)} bytes of PNG data") # Write it manually to file save_path = os.path.join(app.config['UPLOAD_FOLDER'], saved_filename) with open(save_path, 'wb') as f: f.write(image_data) file_size = os.path.getsize(save_path) app.logger.info(f"PNG DEBUG: {image_log_prefix} Direct binary PNG write successful: {file_size/1024:.1f}KB") # Verify the saved file try: with open(save_path, 'rb') as f: saved_header = f.read(16) header_hex = ' '.join([f"{b:02x}" for b in saved_header]) app.logger.info(f"PNG DEBUG: {image_log_prefix} Saved PNG header: {header_hex}") is_valid = saved_header.startswith(png_signature) app.logger.info(f"PNG DEBUG: {image_log_prefix} Saved PNG has valid signature: {is_valid}") if is_valid: # Additional validation with PIL try: with Image.open(save_path) as img: app.logger.info(f"PNG DEBUG: {image_log_prefix} Saved PNG validates with PIL: {img.format} {img.size}") except Exception as pil_err: app.logger.error(f"PNG DEBUG: {image_log_prefix} Saved PNG fails PIL validation: {str(pil_err)}") except Exception as verify_err: app.logger.error(f"PNG DEBUG: {image_log_prefix} Error verifying saved PNG: {str(verify_err)}") except Exception as png_write_err: app.logger.error(f"PNG DEBUG: {image_log_prefix} Direct PNG write failed: {str(png_write_err)}") app.logger.error(f"PNG DEBUG: {image_log_prefix} Error type: {type(png_write_err).__name__}") # Log traceback for PNG errors import io tb_output = io.StringIO() traceback.print_exc(file=tb_output) app.logger.error(f"PNG DEBUG: {image_log_prefix} Binary write traceback:\n{tb_output.getvalue()}") # Fall back to standard method app.logger.info(f"PNG DEBUG: {image_log_prefix} Attempting standard PIL save method") image.seek(0) image.save(os.path.join(app.config['UPLOAD_FOLDER'], saved_filename)) else: # Standard save for non-PNG files image.save(os.path.join(app.config['UPLOAD_FOLDER'], saved_filename)) app.logger.info(f"{image_log_prefix} Standard save successful") except Exception as save_err: app.logger.error(f"{image_log_prefix} Failed to save file: {str(save_err)}") if is_png: app.logger.error(f"PNG DEBUG: {image_log_prefix} Standard PNG save failed: {str(save_err)}") app.logger.error(f"PNG DEBUG: {image_log_prefix} Error type: {type(save_err).__name__}") # Log traceback for PNG errors import io tb_output = io.StringIO() traceback.print_exc(file=tb_output) app.logger.error(f"PNG DEBUG: {image_log_prefix} Standard save traceback:\n{tb_output.getvalue()}") # Try one last method - save as a different format try: app.logger.info(f"PNG DEBUG: {image_log_prefix} Attempting last resort conversion to WebP") image.seek(0) # Try to convert PNG to WebP as a last resort with Image.open(image) as img: # Ensure RGBA for transparency if img.mode != 'RGBA': img = img.convert('RGBA') webp_path = os.path.splitext(os.path.join(app.config['UPLOAD_FOLDER'], saved_filename))[0] + '.webp' img.save(webp_path, 'WEBP') app.logger.info(f"PNG DEBUG: {image_log_prefix} Successfully saved as WebP instead: {webp_path}") # Update the saved_filename to reflect the new extension saved_filename = os.path.basename(webp_path) except Exception as webp_err: app.logger.error(f"PNG DEBUG: {image_log_prefix} Final WebP conversion failed: {str(webp_err)}") traceback.print_exc() error_count += 1 continue # Verify the file was saved correctly saved_path = os.path.join(app.config['UPLOAD_FOLDER'], saved_filename) if not os.path.exists(saved_path) or os.path.getsize(saved_path) == 0: app.logger.error(f"{image_log_prefix} Saved file is missing or empty: {saved_path}") if is_png: app.logger.error(f"PNG DEBUG: {image_log_prefix} Saved PNG is missing or empty: {saved_path}") error_count += 1 continue # Special verification for PNG files if is_png: try: app.logger.info(f"PNG DEBUG: {image_log_prefix} Verifying saved PNG: {saved_path}") saved_size = os.path.getsize(saved_path) / 1024.0 # in KB app.logger.info(f"PNG DEBUG: {image_log_prefix} Saved PNG size: {saved_size:.1f}KB") # Check file integrity by trying to open it try: with Image.open(saved_path) as img: png_width, png_height = img.size png_mode = img.mode app.logger.info(f"PNG DEBUG: {image_log_prefix} Saved PNG valid: {png_width}x{png_height}, mode: {png_mode}") except Exception as png_verify_err: app.logger.error(f"PNG DEBUG: {image_log_prefix} PNG verification failed: {str(png_verify_err)}") # Try to fix by copying from the original try: image.seek(0) with open(saved_path, 'wb') as f: f.write(image.read()) app.logger.info(f"PNG DEBUG: {image_log_prefix} Attempted to fix PNG by direct copy") except Exception as png_fix_err: app.logger.error(f"PNG DEBUG: {image_log_prefix} PNG fix attempt failed: {str(png_fix_err)}") except Exception as e: app.logger.error(f"PNG DEBUG: {image_log_prefix} PNG verification error: {str(e)}") # Generate optimized versions (thumbnails and previews) optimization_success = False try: # Log original file size before optimization original_path = os.path.join(app.config['UPLOAD_FOLDER'], saved_filename) original_size = os.path.getsize(original_path) # Get original image dimensions original_dimensions = "unknown" try: with Image.open(original_path) as img: original_dimensions = f"{img.width}x{img.height}" if is_png: app.logger.info(f"PNG DEBUG: {image_log_prefix} Original PNG dimensions: {original_dimensions}, mode: {img.mode}") except Exception as dim_err: app.logger.warning(f"{image_log_prefix} Could not get image dimensions: {str(dim_err)}") if is_png: app.logger.error(f"PNG DEBUG: {image_log_prefix} Could not get PNG dimensions: {str(dim_err)}") app.logger.error(f"PNG DEBUG: {image_log_prefix} Error type: {type(dim_err).__name__}") traceback.print_exc() app.logger.info(f"{image_log_prefix} Starting optimization for {saved_filename} ({original_size/1024:.1f}KB, {original_dimensions})") # PNG-specific optimization options if is_png: app.logger.info(f"PNG DEBUG: {image_log_prefix} Starting PNG optimization") # For PNGs, we might need different parameters optimization_result = generate_optimized_versions( saved_filename, max_original_width=500, target_size_kb=100, # Higher target for PNGs to maintain transparency debug_prefix=f"PNG DEBUG: {image_log_prefix}" ) else: # Standard optimization for non-PNG images optimization_result = generate_optimized_versions(saved_filename, max_original_width=500, target_size_kb=80) # Log file size after optimization if optimization_result['success'] and optimization_result['original']: optimized_name = optimization_result['original'] optimized_path = os.path.join(app.config['UPLOAD_FOLDER'], optimized_name) if os.path.exists(optimized_path): optimized_size = os.path.getsize(optimized_path) reduction = (1 - (optimized_size / original_size)) * 100 if original_size > 0 else 0 # Get optimized dimensions optimized_dimensions = "unknown" try: with Image.open(optimized_path) as img: optimized_dimensions = f"{img.width}x{img.height}" except Exception as dim_err: app.logger.warning(f"{image_log_prefix} Could not get optimized dimensions: {str(dim_err)}") app.logger.info( f"{image_log_prefix} Optimization results:\n" f" File: {saved_filename} → {optimized_name}\n" f" Size: {original_size/1024:.1f}KB → {optimized_size/1024:.1f}KB ({reduction:.1f}% reduction)\n" f" Dimensions: {original_dimensions} → {optimized_dimensions}" ) # Use the optimized filename saved_filename = optimized_name else: app.logger.warning(f"{image_log_prefix} Optimized file reported success but not found: {optimized_path}") else: app.logger.warning(f"{image_log_prefix} Optimization failed or returned no file") except Exception as e: app.logger.error(f"{image_log_prefix} Optimization failed: {str(e)}") traceback.print_exc() # No fallback thumbnail generation needed as we only use the main image # Always add the filename to our list even if optimization failed # We'll use the original in that case image_filenames.append(saved_filename) processed_count += 1 app.logger.info(f"{image_log_prefix} Successfully processed") except Exception as e: app.logger.error(f"{image_log_prefix} Unexpected error: {str(e)}") traceback.print_exc() error_count += 1 # Continue with the next image # Log summary of upload session app.logger.info(f"Upload session {upload_session_id} completed: {processed_count} processed, {error_count} errors, {skipped_count} skipped") # Handle duplicate images if duplicating if duplicate_images: app.logger.info(f"Processing {len(duplicate_images)} duplicate images: {duplicate_images}") # For mobile browsers, we need to verify the duplicate images exist first verified_duplicates = [] for dup_img in duplicate_images: # Try looking in different paths # Add all possible paths where images might be stored dev_upload_path = app.config['UPLOAD_FOLDER'] prod_upload_path = '/var/Inventarsystem/Web/uploads' # Also look for image variations with suffixes that might be in the path name_part, ext_part = os.path.splitext(dup_img) possible_filenames = [ dup_img, f"{name_part}.webp", # Check WebP first f"{name_part}.jpg", # In case it was converted to JPG f"{name_part}.png", # In case it was saved as PNG ] possible_paths = [] for filename in possible_filenames: possible_paths.extend([ os.path.join(dev_upload_path, filename), # Development upload path os.path.join(prod_upload_path, filename), # Production upload path ]) app.logger.info(f"Looking for duplicate image {dup_img} in paths: {possible_paths}") # Try to find the original image found = False for path in possible_paths: if os.path.exists(path) and os.path.isfile(path): verified_duplicates.append((dup_img, path)) app.logger.info(f"Found duplicate image at: {path}") found = True break if not found: app.logger.warning(f"Duplicate image not found: {dup_img}") # Try to find any image with a similar filename (removing size or resolution parts) # This handles cases where the filename may have variations like "_800" suffix base_name = os.path.splitext(dup_img)[0] base_name = re.sub(r'_\d+$', '', base_name) # Remove trailing _NUMBER if len(base_name) > 5: # Only if we have a meaningful base name app.logger.info(f"Trying to find similar images with base name: {base_name}") # Search in development directory dev_files = os.listdir(app.config['UPLOAD_FOLDER']) if os.path.exists(app.config['UPLOAD_FOLDER']) else [] # Search in production directory prod_path = "/var/Inventarsystem/Web/uploads" prod_files = os.listdir(prod_path) if os.path.exists(prod_path) else [] # Combine all files all_files = dev_files + prod_files # Find similar files for f in all_files: if base_name in f: img_path = os.path.join(app.config['UPLOAD_FOLDER'], f) if not os.path.exists(img_path): img_path = os.path.join(prod_path, f) if os.path.exists(img_path) and os.path.isfile(img_path): app.logger.info(f"Found similar image: {f} at {img_path}") verified_duplicates.append((f, img_path)) found = True break # If we still can't find anything, just use a placeholder if not found: app.logger.warning(f"Could not find any similar image for: {dup_img}, will use placeholder") # Create copies of verified images with new unique filenames duplicate_image_copies = [] # Create a placeholder name for each image that wasn't found placeholder_used = False original_count = len(duplicate_images) # Process each original image - either use found file or placeholder for i, dup_img in enumerate(duplicate_images): # Look for corresponding verified image found_image = None for verified_img, src_path in verified_duplicates: if verified_img == dup_img: found_image = (verified_img, src_path) break try: # Generate a new unique filename (same for real or placeholder) unique_id = str(uuid.uuid4()) timestamp = time.strftime("%Y%m%d%H%M%S") _, ext_part = os.path.splitext(dup_img) if dup_img else '.jpg' new_filename = f"{unique_id}_{timestamp}{ext_part}" # If we found the image, copy it if found_image: dup_img, src_path = found_image app.logger.info(f"Copying image {i+1}/{original_count} from {src_path} to {new_filename}") # Copy the image file to the new name dst_path = os.path.join(app.config['UPLOAD_FOLDER'], new_filename) # Make sure the target directory exists os.makedirs(os.path.dirname(dst_path), exist_ok=True) # Copy the file shutil.copy2(src_path, dst_path) # Verify the file was copied successfully if os.path.exists(dst_path): app.logger.info(f"Successfully copied image to {dst_path}") else: app.logger.error(f"Failed to copy image to {dst_path}") # If copy fails, use placeholder raise Exception("Copy failed - will use placeholder") # Generate optimized versions (thumbnails and previews) for the new copy try: result = generate_optimized_versions(new_filename, max_original_width=500, target_size_kb=80) app.logger.info(f"Generated optimized versions: {result}") if result['success'] and result['original']: new_filename = result['original'] except Exception as e: app.logger.error(f"Error generating optimized versions for {new_filename}: {e}") # If optimization fails, at least keep the original file result = {'original': new_filename} traceback.print_exc() # If we didn't find the image, use a placeholder else: app.logger.warning(f"Using placeholder for image {i+1}/{original_count} (original: {dup_img})") # Copy placeholder to uploads directory with the new filename placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.svg') if not os.path.exists(placeholder_path): placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.png') if os.path.exists(placeholder_path): dst_path = os.path.join(app.config['UPLOAD_FOLDER'], new_filename) shutil.copy2(placeholder_path, dst_path) app.logger.info(f"Copied placeholder image to {dst_path}") placeholder_used = True # Skip the optimization step for placeholder images # Just add directly to the list of image filenames continue else: app.logger.error(f"Placeholder image not found at {placeholder_path}") # Create a simple placeholder file with open(os.path.join(app.config['UPLOAD_FOLDER'], new_filename), 'w') as f: f.write("Placeholder") placeholder_used = True # Skip the optimization step continue # Add the new filename to our list (either copied or placeholder) duplicate_image_copies.append(new_filename) processed_count += 1 app.logger.info(f"Processed image {i+1}/{original_count}: {new_filename}") except Exception as e: app.logger.error(f"Error processing image {i+1}/{original_count} ({dup_img}): {str(e)}") traceback.print_exc() error_count += 1 # Log placeholder usage if placeholder_used: app.logger.warning(f"Used placeholders for some missing images during duplication") # Log if no images were processed if not duplicate_image_copies: app.logger.warning(f"No duplicate images were processed") if duplicate_images: app.logger.warning(f"Original had {len(duplicate_images)} images, but none were copied") # Add the new image copies to our list of filenames image_filenames.extend(duplicate_image_copies) # Handle book cover image if provided if book_cover_image: # Verify the book cover image exists full_path = os.path.join(app.config['UPLOAD_FOLDER'], book_cover_image) if os.path.exists(full_path) and os.path.isfile(full_path): # Create a unique filename for the book cover unique_id = str(uuid.uuid4()) timestamp = time.strftime("%Y%m%d%H%M%S") _, ext_part = os.path.splitext(book_cover_image) new_filename = f"{unique_id}_{timestamp}_book_cover{ext_part}" new_path = os.path.join(app.config['UPLOAD_FOLDER'], new_filename) # Copy the file to the new unique name shutil.copy2(full_path, new_path) # Use the new filename instead image_filenames.append(new_filename) app.logger.info(f"Copied book cover from {book_cover_image} to {new_filename}") else: app.logger.warning(f"Book cover image not found: {book_cover_image}") # Log image processing stats app.logger.info(f"Upload stats: processed={processed_count}, errors={error_count}, skipped={skipped_count}, duplicates={len(duplicate_images) if duplicate_images else 0}") # If location is not in the predefined list, add it predefined_locations = it.get_predefined_locations() if ort and ort not in predefined_locations: it.add_predefined_location(ort) reservierbar = 'reservierbar' in request.form # Add one or more own items; sub-items are hidden in overview and counted on parent. created_item_ids = [] series_group_id = str(uuid.uuid4()) if item_count > 1 else None base_code = code_4[0] if code_4 else None for position in range(1, item_count + 1): unique_code = None if position <= len(individual_codes): unique_code = individual_codes[position - 1] else: unique_code = generate_unique_batch_code(base_code, position) if (base_code or individual_codes) and not unique_code: error_msg = 'Fehler bei der Code-Erzeugung für mehrere Artikel.' if is_mobile: return jsonify({'success': False, 'message': error_msg}), 400 flash(error_msg, 'error') return redirect(url_for('home_admin')) parent_item_id = str(created_item_ids[0]) if created_item_ids else None item_id = it.add_item( name, ort, beschreibung, image_filenames, filter_upload, filter_upload2, filter_upload3, anschaffungs_jahr[0] if anschaffungs_jahr else None, anschaffungs_kosten[0] if anschaffungs_kosten else None, unique_code, reservierbar=reservierbar, series_group_id=series_group_id, series_count=item_count, series_position=position, is_grouped_sub_item=(position > 1), parent_item_id=parent_item_id, isbn=item_isbn, item_type=item_type ) if not item_id: break created_item_ids.append(item_id) if len(created_item_ids) != item_count: error_msg = f'Nur {len(created_item_ids)} von {item_count} Artikeln konnten erstellt werden.' if is_mobile: return jsonify({'success': False, 'message': error_msg}), 500 flash(error_msg, 'error') return redirect(url_for('home_admin')) item_id = created_item_ids[0] if created_item_ids else None if item_id: # Create QR code for the item (deactivated) # create_qr_code(str(item_id)) success_msg = f'Element wurde erfolgreich hinzugefügt ({len(created_item_ids)} erstellt)' if is_mobile: return jsonify({ 'success': True, 'message': success_msg, 'itemId': str(item_id), 'stats': { 'processed': processed_count, 'errors': error_count, 'skipped': skipped_count, 'duplicates': len(duplicate_images) if duplicate_images else 0, 'totalImages': len(image_filenames) } }) else: flash(success_msg, 'success') return redirect(url_for('home_admin', highlight_item=str(item_id))) else: error_msg = 'Fehler beim Hinzufügen des Elements' if is_mobile: return jsonify({'success': False, 'message': error_msg}), 500 else: flash(error_msg, 'error') return redirect(url_for('home_admin')) @app.route('/duplicate_item', methods=['POST']) def duplicate_item(): """ Route for duplicating an existing item. Returns JSON response with success status. Enhanced for mobile browser compatibility. Returns: flask.Response: JSON response with success status and data """ try: # Check authentication if 'username' not in session: return jsonify({'success': False, 'message': 'Nicht angemeldet'}), 401 # Check if user is admin username = session['username'] if not us.check_admin(username): return jsonify({'success': False, 'message': 'Keine Administratorrechte'}), 403 # Detect if request is from mobile device is_mobile = 'Mobile' in request.headers.get('User-Agent', '') is_ios = 'iPhone' in request.headers.get('User-Agent', '') or 'iPad' in request.headers.get('User-Agent', '') # Log mobile duplication for debugging if is_mobile: app.logger.info(f"Mobile duplication from {request.headers.get('User-Agent', 'unknown')} by {username}") # Get original item ID original_item_id = request.form.get('original_item_id') if not original_item_id: return jsonify({'success': False, 'message': 'Ursprungs-Element-ID fehlt'}), 400 # Fetch original item data original_item = it.get_item(original_item_id) if not original_item: return jsonify({'success': False, 'message': 'Ursprungs-Element nicht gefunden'}), 404 # Process filters as arrays (same as stored in database) filter1_array = original_item.get('Filter', []) filter2_array = original_item.get('Filter2', []) filter3_array = original_item.get('Filter3', []) # Ensure filters are arrays if not isinstance(filter1_array, list): filter1_array = [filter1_array] if filter1_array else [] if not isinstance(filter2_array, list): filter2_array = [filter2_array] if filter2_array else [] if not isinstance(filter3_array, list): filter3_array = [filter3_array] if filter3_array else [] # Verify image paths for mobile devices to avoid issues with non-existent images images = original_item.get('Images', []) verified_images = [] if is_mobile: for img in images: img_path = os.path.join(app.config['UPLOAD_FOLDER'], img) if os.path.exists(img_path) and os.path.isfile(img_path): verified_images.append(img) else: app.logger.warning(f"Image not found for duplication: {img}") # If we lost images in verification, log it if len(verified_images) < len(images): app.logger.warning(f"Only {len(verified_images)} of {len(images)} images verified for mobile duplication") else: verified_images = images # For iOS devices, add more diagnostics and reduce data size if needed if is_ios: # Check if images exist (we now use main images as thumbnails) images_exist = [] for img in verified_images[:5]: # Only check first 5 to save time img_path = os.path.join(app.config['UPLOAD_FOLDER'], img) if os.path.exists(img_path): images_exist.append(True) else: images_exist.append(False) # Log detailed diagnostics app.logger.info(f"iOS duplication details: {len(verified_images)} images, " f"images available: {all(images_exist)}, " f"filter sizes: {len(filter1_array)}, {len(filter2_array)}, {len(filter3_array)}") return jsonify({ 'success': True, 'message': 'Duplizierungsdaten erfolgreich vorbereitet', 'item_data': { 'name': original_item.get('Name', ''), 'description': original_item.get('Beschreibung', ''), 'location': original_item.get('Ort', ''), 'room': original_item.get('Raum', ''), 'category': original_item.get('Kategorie', ''), 'year': original_item.get('Anschaffungsjahr', ''), 'cost': original_item.get('Anschaffungskosten', ''), 'filter1': filter1_array, 'filter2': filter2_array, 'filter3': filter3_array, 'images': verified_images, # Using verified images instead of original 'isMobile': is_mobile, 'isIOS': is_ios } }) except Exception as e: print(f"Error in duplicate_item: {e}") traceback.print_exc() return jsonify({'success': False, 'message': 'Serverfehler beim Duplizieren'}), 500 @app.route('/delete_item/', methods=['POST', 'GET']) def delete_item(id): """ Route for deleting inventory items. Args: id (str): ID of the item to delete Returns: flask.Response: Redirect to admin homepage """ 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') return redirect(url_for('login')) if not us.check_admin(session['username']): flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') return redirect(url_for('login')) # Resolve all related item ids (grouped variants) and load their data group_item_ids = it.get_group_item_ids(id) if not group_item_ids: flash('Element nicht gefunden.', 'error') return redirect(url_for('home_admin')) group_items = [] for group_item_id in group_item_ids: group_item = it.get_item(group_item_id) if group_item: group_items.append(group_item) if not group_items: flash('Element nicht gefunden.', 'error') return redirect(url_for('home_admin')) # Collect all referenced images once to avoid duplicate deletion attempts image_filenames = [] seen_images = set() for group_item in group_items: for filename in group_item.get('Images', []): if filename and filename not in seen_images: seen_images.add(filename) image_filenames.append(filename) # Attempt to delete image files stats = {'originals': 0, 'thumbnails': 0, 'previews': 0, 'errors': 0} try: stats = delete_item_images(image_filenames) app.logger.info(f"Item group deletion ({len(group_item_ids)} IDs) - Images removed: " + f"originals={stats['originals']}, thumbnails={stats['thumbnails']}, " + f"previews={stats['previews']}, errors={stats['errors']}") except Exception as e: app.logger.error(f"Error deleting images for item group {id}: {str(e)}") # Delete all items in the group and related borrow entries delete_success = True for group_item_id in group_item_ids: if not it.remove_item(group_item_id): delete_success = False try: client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] db['ausleihungen'].delete_many({'Item': {'$in': group_item_ids}}) client.close() except Exception as e: app.logger.error(f"Error deleting borrowing records for item group {id}: {str(e)}") if delete_success: flash(f'Elementgruppe erfolgreich gelöscht ({len(group_item_ids)} Versionen). {stats["originals"]} Bilder entfernt.', 'success') else: flash('Fehler beim Löschen des Elements aus der Datenbank.', 'error') return redirect(url_for('home_admin')) @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 """ 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') return redirect(url_for('login')) if not us.check_admin(session['username']): flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') return redirect(url_for('login')) # Strip whitespace from all text fields name = sanitize_form_value(request.form.get('name')) ort = sanitize_form_value(request.form.get('ort')) beschreibung = sanitize_form_value(request.form.get('beschreibung')) # Strip whitespace from all filter values filter1 = sanitize_form_value(request.form.getlist('filter')) filter2 = sanitize_form_value(request.form.getlist('filter2')) filter3 = sanitize_form_value(request.form.getlist('filter3')) 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.LIBRARY_MODULE_ENABLED: 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' # Check if code is unique (excluding the current item) 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')) # Get current item to check availability status current_item = it.get_item(id) if not current_item: flash('Element nicht gefunden', 'error') return redirect(url_for('home_admin')) # Preserve current availability status verfuegbar = current_item.get('Verfuegbar', True) # Handle existing images - get list of images to keep images_to_keep = request.form.getlist('existing_images') # Get the original list of images from the item original_images = current_item.get('Images', []) # Keep only the images that weren't marked for deletion images = [img for img in original_images if img in images_to_keep] # Handle new image uploads new_images = request.files.getlist('new_images') # Process any new image uploads for image in new_images: if image and image.filename: is_allowed, error_message = allowed_file(image.filename) if is_allowed: # Get the file extension _, ext_part = os.path.splitext(secure_filename(image.filename)) # Generate a completely unique filename using UUID unique_id = str(uuid.uuid4()) timestamp = time.strftime("%Y%m%d%H%M%S") # New filename format with UUID to ensure uniqueness filename = f"{unique_id}_{timestamp}{ext_part}" image.save(os.path.join(app.config['UPLOAD_FOLDER'], filename)) # Optimize the image try: opt_result = generate_optimized_versions(filename, max_original_width=500, target_size_kb=80) if opt_result['success'] and opt_result['original']: filename = opt_result['original'] except Exception as e: app.logger.error(f"Error optimizing image in edit_item: {e}") images.append(filename) else: flash(error_message, 'error') return redirect(url_for('home_admin')) # If location is not in the predefined list, maybe add it (depending on policy) predefined_locations = it.get_predefined_locations() if ort and ort not in predefined_locations: it.add_predefined_location(ort) # Update the item result = it.update_item( id, name, ort, beschreibung, images, verfuegbar, filter1, filter2, filter3, anschaffungs_jahr, anschaffungs_kosten, code_4, reservierbar, isbn=item_isbn, item_type=item_type ) if result: flash('Element erfolgreich aktualisiert', 'success') else: flash('Fehler beim Aktualisieren des Elements', 'error') return redirect(url_for('home_admin')) @app.route('/report_damage/', methods=['POST']) def report_damage(id): """Register a damage report entry for an item (admin only).""" if 'username' not in session: return jsonify({'success': False, 'message': 'Nicht angemeldet'}), 401 if not us.check_admin(session['username']): return jsonify({'success': False, 'message': 'Administratorrechte erforderlich'}), 403 payload = request.get_json(silent=True) or {} description = str(payload.get('description', '')).strip() or 'Schaden gemeldet' if len(description) > 1000: return jsonify({'success': False, 'message': 'Die Schadensbeschreibung ist zu lang (max. 1000 Zeichen).'}), 400 client = None try: client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] items_col = db['items'] item_doc = items_col.find_one({'_id': ObjectId(id)}, {'Name': 1}) if not item_doc: return jsonify({'success': False, 'message': 'Objekt nicht gefunden.'}), 404 now = datetime.datetime.now() damage_entry = { 'description': description, 'reported_by': session['username'], 'reported_at': now, } result = items_col.update_one( {'_id': ObjectId(id)}, { '$push': {'DamageReports': {'$each': [damage_entry], '$position': 0}}, '$set': {'HasDamage': True, 'LastUpdated': now} } ) if result.matched_count == 0: return jsonify({'success': False, 'message': 'Objekt nicht gefunden.'}), 404 updated_item = items_col.find_one({'_id': ObjectId(id)}, {'DamageReports': 1}) damage_count = len(updated_item.get('DamageReports', [])) if updated_item else 0 # Best-effort system log entry for auditability try: logs_collection = db['system_logs'] logs_collection.insert_one({ 'type': 'damage_report', 'timestamp': now.isoformat(), 'user': session.get('username'), 'item_id': id, 'item_name': item_doc.get('Name', ''), 'note': description, 'damage_count': damage_count, 'ip': request.remote_addr }) except Exception as log_err: app.logger.warning(f"Damage report log write failed for item {id}: {log_err}") return jsonify({ 'success': True, 'message': 'Schaden erfolgreich erfasst.', 'damage_count': damage_count }) except Exception as e: app.logger.error(f"Error reporting damage for item {id}: {e}") return jsonify({'success': False, 'message': 'Fehler beim Speichern der Schadensmeldung.'}), 500 finally: if client: client.close() @app.route('/mark_damage_repaired/', methods=['POST']) def mark_damage_repaired(id): """Mark all currently open damage reports of an item as repaired (admin only).""" if 'username' not in session: return jsonify({'success': False, 'message': 'Nicht angemeldet'}), 401 if not us.check_admin(session['username']): return jsonify({'success': False, 'message': 'Administratorrechte erforderlich'}), 403 client = None try: client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] items_col = db['items'] ausleihungen_col = db['ausleihungen'] item = items_col.find_one({'_id': ObjectId(id)}, {'DamageReports': 1, 'DamageRepairs': 1}) if not item: return jsonify({'success': False, 'message': 'Objekt nicht gefunden.'}), 404 open_reports = item.get('DamageReports', []) if not open_reports: return jsonify({'success': False, 'message': 'Keine offenen Schäden vorhanden.'}), 400 active_borrow = ausleihungen_col.find_one({'Item': str(id), 'Status': 'active'}, {'_id': 1}) now = datetime.datetime.now() repair_entry = { 'repaired_by': session['username'], 'repaired_at': now, 'resolved_reports': open_reports } result = items_col.update_one( {'_id': ObjectId(id)}, { '$push': {'DamageRepairs': {'$each': [repair_entry], '$position': 0}}, '$set': { 'DamageReports': [], 'HasDamage': False, 'LastUpdated': now, **({} if active_borrow else {'Verfuegbar': True}), } } ) if not active_borrow: items_col.update_one( {'_id': ObjectId(id)}, { '$unset': {'User': '', 'Condition': ''} } ) if result.matched_count == 0: return jsonify({'success': False, 'message': 'Objekt nicht gefunden.'}), 404 # Best-effort system log entry for repair action try: logs_collection = db['system_logs'] logs_collection.insert_one({ 'type': 'damage_repair', 'timestamp': now.isoformat(), 'user': session.get('username'), 'item_id': id, 'resolved_count': len(open_reports), 'ip': request.remote_addr }) except Exception as log_err: app.logger.warning(f"Damage repair log write failed for item {id}: {log_err}") return jsonify({ 'success': True, 'message': 'Schäden als repariert markiert.', 'resolved_count': len(open_reports) }) except Exception as e: app.logger.error(f"Error marking damages repaired for item {id}: {e}") return jsonify({'success': False, 'message': 'Fehler beim Markieren als repariert.'}), 500 finally: if client: client.close() @app.route('/get_ausleihungen', methods=['GET']) def get_ausleihungen(): """ API endpoint to retrieve all borrowing records. Returns: dict: Dictionary containing all borrowing records """ ausleihungen = au.get_ausleihungen() return {'ausleihungen': ausleihungen} @app.route('/ausleihen/', methods=['POST']) def ausleihen(id): """ Route for borrowing an item from inventory. Args: id (str): ID of the item to borrow Returns: flask.Response: Redirect to appropriate homepage """ 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') return redirect(url_for('login')) username = session['username'] requested_return_to = (request.form.get('return_to') or '').strip().lower() if requested_return_to == 'library' and cfg.LIBRARY_MODULE_ENABLED: redirect_target = 'library_view' else: redirect_target = 'home_admin' if us.check_admin(username) else 'home' item = it.get_item(id) if not item: flash('Element nicht gefunden', 'error') return redirect(url_for(redirect_target)) effective_borrower = username borrow_duration_days = None student_card_id = us.normalize_student_card_id(request.form.get('borrower_card_id')) item_type = str(item.get('ItemType', '')).strip().lower() is_library_item = item_type in LIBRARY_ITEM_TYPES # Library media can only be borrowed with a valid student card. if is_library_item: if not cfg.STUDENT_CARDS_MODULE_ENABLED: flash('Bibliotheksmedien können nur mit aktivem Schülerausweis-Modul ausgeliehen werden.', 'error') return redirect(url_for(redirect_target)) if not student_card_id: flash('Für Bibliotheksmedien ist eine gültige Schülerausweis-ID erforderlich.', 'error') return redirect(url_for(redirect_target)) client = None try: client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] student_cards_col = db['student_cards'] card_doc = student_cards_col.find_one({'AusweisId': student_card_id}) if not card_doc: flash('Ungültige Schülerausweis-ID. Bibliotheksmedien können nur mit gültigem Ausweis ausgeliehen werden.', 'error') return redirect(url_for(redirect_target)) effective_borrower = card_doc.get('SchülerName') or f"Ausweis {student_card_id}" if not (request.form.get('borrow_duration_days') or '').strip(): try: card_default = int(card_doc.get('StandardAusleihdauer', cfg.STUDENT_DEFAULT_BORROW_DAYS)) except (TypeError, ValueError): card_default = cfg.STUDENT_DEFAULT_BORROW_DAYS borrow_duration_days = max(1, min(card_default, cfg.STUDENT_MAX_BORROW_DAYS)) finally: if client: client.close() if cfg.STUDENT_CARDS_MODULE_ENABLED: duration_raw = (request.form.get('borrow_duration_days') or '').strip() if duration_raw: try: parsed_duration = int(duration_raw) if 1 <= parsed_duration <= cfg.STUDENT_MAX_BORROW_DAYS: borrow_duration_days = parsed_duration else: flash(f'Ausleihdauer muss zwischen 1 und {cfg.STUDENT_MAX_BORROW_DAYS} Tagen liegen.', 'error') return redirect(url_for(redirect_target)) except ValueError: flash('Ungültige Ausleihdauer angegeben.', 'error') return redirect(url_for(redirect_target)) # Admins can borrow on behalf of students via student card id. if us.check_admin(username) and not is_library_item: if student_card_id: student_user = us.get_user_by_student_card(student_card_id) if not student_user: flash('Keine Schülerin/kein Schüler mit dieser Ausweis-ID gefunden.', 'error') return redirect(url_for('home_admin')) effective_borrower = student_user.get('Username') or student_user.get('username') or username if borrow_duration_days is None: try: card_default = int(student_user.get('MaxBorrowDays', cfg.STUDENT_DEFAULT_BORROW_DAYS)) except (TypeError, ValueError): card_default = cfg.STUDENT_DEFAULT_BORROW_DAYS borrow_duration_days = max(1, min(card_default, cfg.STUDENT_MAX_BORROW_DAYS)) start_date = datetime.datetime.now() end_date = None if borrow_duration_days: end_date = start_date + datetime.timedelta(days=borrow_duration_days) # Grouped inventory mode: parent item + hidden sub-items are handled as separate physical units. try: client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] items_col = db['items'] grouped_children = list(items_col.find({'ParentItemId': id, 'IsGroupedSubItem': True})) client.close() except Exception: grouped_children = [] if grouped_children: exemplare_count = request.form.get('exemplare_count', 1) specific_item_id = (request.form.get('specific_item_id') or '').strip() try: exemplare_count = int(exemplare_count) if exemplare_count < 1: exemplare_count = 1 except (ValueError, TypeError): exemplare_count = 1 grouped_units = [item] + [{**child, '_id': str(child.get('_id'))} for child in grouped_children] available_units = [unit for unit in grouped_units if unit.get('Verfuegbar', True)] planned_counts = {} try: client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] ausleihungen_col = db['ausleihungen'] unit_ids = [str(unit.get('_id')) for unit in grouped_units if unit.get('_id')] planned_cursor = ausleihungen_col.find({'Item': {'$in': unit_ids}, 'Status': 'planned'}) for planned_booking in planned_cursor: planned_item_id = str(planned_booking.get('Item') or '') if not planned_item_id: continue planned_counts[planned_item_id] = planned_counts.get(planned_item_id, 0) + 1 client.close() except Exception: planned_counts = {} if not available_units: flash('Keine Exemplare verfügbar.', 'error') return redirect(url_for(redirect_target)) selected_units = [] if specific_item_id: chosen = next((unit for unit in available_units if str(unit.get('_id')) == specific_item_id), None) if not chosen: flash('Das ausgewählte Exemplar ist nicht verfügbar.', 'error') return redirect(url_for(redirect_target)) selected_units = [chosen] else: available_units.sort(key=lambda unit: (planned_counts.get(str(unit.get('_id')) or '', 0), str(unit.get('Code_4') or ''), str(unit.get('_id') or ''))) if exemplare_count > len(available_units): flash(f'Nicht genügend Exemplare verfügbar. Angefordert: {exemplare_count}, Verfügbar: {len(available_units)}', 'error') return redirect(url_for(redirect_target)) selected_units = available_units[:exemplare_count] for unit in selected_units: unit_id = str(unit.get('_id')) it.update_item_status(unit_id, False, effective_borrower) au.add_ausleihung(unit_id, effective_borrower, start_date, end_date=end_date) if len(selected_units) == 1: selected_code = selected_units[0].get('Code_4') or '-' flash(f'Exemplar {selected_code} erfolgreich ausgeliehen', 'success') else: flash(f'{len(selected_units)} Exemplare erfolgreich ausgeliehen', 'success') return redirect(url_for(redirect_target)) # Before borrowing, block if there's a conflicting planned booking try: now = datetime.datetime.now() # Fetch planned bookings for this item from DB planned = au.get_planned_ausleihungen() # Count relevant upcoming planned bookings for today or ongoing upcoming_planned_today = [] for appt in planned: appt_item = str(appt.get('Item')) if appt.get('Item') is not None else None if appt_item != id: continue appt_start = appt.get('Start') appt_end = appt.get('End') or appt_start if not appt_start: continue # Consider conflict if appointment ends in the future and is today try: if appt_end >= now and appt_start.date() == now.date(): upcoming_planned_today.append(appt) except Exception: # Fallback simple check if appt_start.date() == now.date(): upcoming_planned_today.append(appt) if upcoming_planned_today: # For single-instance items, block outright; for multi-exemplar, allow only if capacity suffices item_doc = it.get_item(id) total_exemplare = item_doc.get('Exemplare', 1) if item_doc else 1 if total_exemplare <= 1: flash('Dieses Objekt hat heute eine geplante Reservierung und kann aktuell nicht ausgeliehen werden.', 'error') return redirect(url_for(redirect_target)) else: # If planned count equals or exceeds remaining capacity, block current_borrowed = len(item_doc.get('ExemplareStatus', [])) if item_doc else 0 if current_borrowed + len(upcoming_planned_today) >= total_exemplare: flash('Alle Exemplare sind aufgrund geplanter Reservierungen heute belegt.', 'error') return redirect(url_for(redirect_target)) except Exception as e: print(f"Warning: could not enforce planned booking guard: {e}") # Get number of exemplars to borrow (default to 1) exemplare_count = request.form.get('exemplare_count', 1) try: exemplare_count = int(exemplare_count) if exemplare_count < 1: exemplare_count = 1 except (ValueError, TypeError): exemplare_count = 1 # Check if the item has exemplars defined total_exemplare = item.get('Exemplare', 1) # Get current exemplar status exemplare_status = item.get('ExemplareStatus', []) # Count how many exemplars are currently available borrowed_count = len(exemplare_status) available_count = total_exemplare - borrowed_count if available_count < exemplare_count: flash(f'Nicht genügend Exemplare verfügbar. Angefordert: {exemplare_count}, Verfügbar: {available_count}', 'error') return redirect(url_for(redirect_target)) # If we reach here, we can borrow the requested number of exemplars current_date = datetime.datetime.now().strftime('%d.%m.%Y %H:%M') # If the item doesn't use exemplars (single item) if total_exemplare <= 1: it.update_item_status(id, False, effective_borrower) start_date = datetime.datetime.now() au.add_ausleihung(id, effective_borrower, start_date, end_date=end_date) flash('Element erfolgreich ausgeliehen', 'success') else: # Handle multi-exemplar item new_borrowed_exemplars = [] # Create new entries for borrowed exemplars for i in range(exemplare_count): # Find the next available exemplar number exemplar_number = 1 used_numbers = [ex.get('number') for ex in exemplare_status] while exemplar_number in used_numbers: exemplar_number += 1 new_borrowed_exemplars.append({ 'number': exemplar_number, 'user': effective_borrower, 'date': current_date }) # Add new borrowed exemplars to the status updated_status = exemplare_status + new_borrowed_exemplars # Update the item with the new status it.update_item_exemplare_status(id, updated_status) # Update the item's availability if all exemplars are borrowed if len(updated_status) >= total_exemplare: it.update_item_status(id, False, username) # Create ausleihung records for each borrowed exemplar start_date = datetime.datetime.now() for exemplar in new_borrowed_exemplars: exemplar_id = f"{id}_{exemplar['number']}" au.add_ausleihung(exemplar_id, effective_borrower, start_date, end_date=end_date, exemplar_data={ 'parent_id': id, 'exemplar_number': exemplar['number'] }) flash(f'{exemplare_count} Exemplare erfolgreich ausgeliehen', 'success') return redirect(url_for(redirect_target)) @app.route('/zurueckgeben/', methods=['POST']) def zurueckgeben(id): """ Route for returning a borrowed item. Creates or updates a record of the borrowing session. Args: id (str): ID of the item to return Returns: flask.Response: Redirect to appropriate homepage """ 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') return redirect(url_for('login')) item = it.get_item(id) if not item: flash('Element nicht gefunden', 'error') return redirect(url_for('home')) username = session['username'] print("Code 1169: zurueckgeben called with item ID:", id) if not item.get('Verfuegbar', True) and (us.check_admin(session['username']) or item.get('User') == username): print("Code 1172: Item is not available, proceeding with return") try: # Get ALL active borrowing records for this item and complete them client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] ausleihungen = db['ausleihungen'] # Find all active records for this item active_records = ausleihungen.find({ 'Item': id, 'Status': 'active' }) end_date = datetime.datetime.now() original_user = item.get('User', username) updated_count = 0 for record in active_records: ausleihung_id = str(record['_id']) print(f"Completing active ausleihung {ausleihung_id} for item {id}") # Update each active record result = ausleihungen.update_one( {'_id': ObjectId(ausleihung_id)}, {'$set': { 'Status': 'completed', 'End': end_date, 'LastUpdated': datetime.datetime.now() }} ) if result.modified_count > 0: updated_count += 1 client.close() # Update the item status it.update_item_status(id, True, original_user) if updated_count > 0: flash(f'Element erfolgreich zurückgegeben ({updated_count} Datensätze abgeschlossen)', 'success') else: flash('Element erfolgreich zurückgegeben', 'success') except Exception as e: print(f"Error in return process: {e}") it.update_item_status(id, True) flash(f'Element zurückgegeben, aber ein Fehler ist aufgetreten: {str(e)}', 'warning') else: flash('Sie sind nicht berechtigt, dieses Element zurückzugeben, oder es ist bereits verfügbar', 'error') # Check if request came from my_borrowed_items page source_page = request.form.get('source_page') referrer = request.headers.get('Referer', '') if source_page == 'my_borrowed_items' or '/my_borrowed_items' in referrer: return redirect(url_for('my_borrowed_items')) if 'username' in session and not us.check_admin(session['username']): return redirect(url_for('home')) return redirect(url_for('home_admin')) @app.route('/get_filter', methods=['GET']) def get_filter(): """ API endpoint to retrieve available item filters/categories. Returns: dict: Dictionary of available filters """ return it.get_filters() @app.route('/get_ausleihung_by_item/') def get_ausleihung_by_item_route(id): """ API endpoint to retrieve borrowing details for a specific item. Args: id (str): ID of the item to retrieve Returns: dict: Borrowing details for the item """ if 'username' not in session: return {'error': 'Not authorized', 'status': 'forbidden'}, 403 # Get the borrowing record ausleihung = au.get_ausleihung_by_item(id, include_history=False) # Add client-side status verification if a borrowing record exists if ausleihung: # Add verified status to each borrowing record with logging current_status = au.get_current_status( ausleihung, log_changes=True, user=session.get('username', None) ) ausleihung['VerifiedStatus'] = current_status # Admin users can see all borrowing details # Regular users can only see their own borrowings if ausleihung and (us.check_admin(session['username']) or ausleihung.get('User') == session['username']): return {'ausleihung': ausleihung, 'status': 'success'} # Get item name for better error message item = it.get_item(id) item_name = item.get('Name', 'Unknown') if item else 'Unknown' # Return a more informative error return { 'error': 'No active borrowing record found for this item', 'item_name': item_name, 'status': 'not_found' }, 200 # Return 200 instead of 404 to allow processing of the error message @app.route('/get_planned_bookings/') def get_planned_bookings(item_id): """ Return all planned bookings for a given item (admin only). """ if 'username' not in session or not us.check_admin(session['username']): return jsonify({'ok': False, 'error': 'unauthorized'}), 403 try: client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] ausleihungen = db['ausleihungen'] cursor = ausleihungen.find({'Item': item_id, 'Status': 'planned'}).sort('Start', 1) bookings = [] for r in cursor: bookings.append({ 'id': str(r.get('_id')), 'user': r.get('User', ''), 'period': r.get('Period'), 'start': r.get('Start').isoformat() if r.get('Start') else None, 'end': r.get('End').isoformat() if r.get('End') else None, 'notes': r.get('Notes', '') }) client.close() return jsonify({'ok': True, 'bookings': bookings}) except Exception as e: return jsonify({'ok': False, 'error': str(e)}), 500 @app.route('/get_planned_bookings_public/') def get_planned_bookings_public(item_id): """ Return planned bookings for a given item (normal users; limited fields, no notes). """ if 'username' not in session: return jsonify({'ok': False, 'error': 'unauthorized'}), 401 try: client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] ausleihungen = db['ausleihungen'] cursor = ausleihungen.find({'Item': item_id, 'Status': 'planned'}).sort('Start', 1) bookings = [] for r in cursor: bookings.append({ 'period': r.get('Period'), 'start': r.get('Start').isoformat() if r.get('Start') else None, 'end': r.get('End').isoformat() if r.get('End') else None }) client.close() return jsonify({'ok': True, 'bookings': bookings}) except Exception as e: return jsonify({'ok': False, 'error': str(e)}), 500 @app.route('/check_availability') def check_availability(): """ Check if a given item is available for the specified date and period range. Query params: item_id, date=YYYY-MM-DD, start=<1-10>, end=<1-10> Returns: { ok, available, conflicts:[...] } """ if 'username' not in session: return jsonify({'ok': False, 'error': 'unauthorized'}), 401 item_id = request.args.get('item_id') date_str = request.args.get('date') start_p = request.args.get('start') end_p = request.args.get('end') or start_p if not item_id or not date_str or not start_p: return jsonify({'ok': False, 'error': 'missing parameters'}), 400 try: # Parse date booking_date = datetime.datetime.strptime(date_str, '%Y-%m-%d') start_num = int(start_p) end_num = int(end_p) if end_num < start_num: start_num, end_num = end_num, start_num # Compute requested time window start_times = get_period_times(booking_date, start_num) end_times = get_period_times(booking_date, end_num) if not start_times or not end_times: return jsonify({'ok': False, 'error': 'invalid period(s)'}), 400 req_start = start_times['start'] req_end = end_times['end'] client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] ausleihungen = db['ausleihungen'] items_col = db['items'] # Collect potential conflicts (planned and active) for this day same_day_start = datetime.datetime.combine(booking_date.date(), datetime.time.min) same_day_end = datetime.datetime.combine(booking_date.date(), datetime.time.max) candidates = list(ausleihungen.find({ 'Item': item_id, 'Status': {'$in': ['planned', 'active']}, 'Start': {'$lte': same_day_end}, 'End': {'$gte': same_day_start} })) conflicts = [] for r in candidates: r_start = r.get('Start') r_end = r.get('End') # If end missing for active, assume lasts through the day if r_end is None: r_end = same_day_end if r_start is None: r_start = same_day_start # Overlap check: req_start < r_end and req_end > r_start if req_start < r_end and req_end > r_start: conflicts.append({ 'id': str(r.get('_id')), 'status': r.get('Status'), 'user': r.get('User', ''), 'start': r_start.isoformat() if r_start else None, 'end': r_end.isoformat() if r_end else None, 'period': r.get('Period') }) # Also include current availability if checking today and item is borrowed now item_doc = items_col.find_one({'_id': ObjectId(item_id)}) if item_doc and not item_doc.get('Verfuegbar', True): now = datetime.datetime.now() if req_start.date() == now.date(): conflicts.append({'status': 'active', 'user': item_doc.get('User'), 'start': None, 'end': None, 'period': None, 'id': None}) client.close() return jsonify({'ok': True, 'available': len(conflicts) == 0, 'conflicts': conflicts}) except Exception as e: return jsonify({'ok': False, 'error': str(e)}), 500 # def create_qr_code(id): # """ # Generate a QR code for an item. # The QR code contains a URL that points to the item details. # # Args: # id (str): ID of the item to generate QR code for # # Returns: # str: Filename of the generated QR code, or None if item not found # """ # qr = qrcode.QRCode( # version=1, # error_correction=ERROR_CORRECT_L, # Use imported constant # box_size=10, # border=4, # ) # # # Parse and reconstruct the URL properly # parsed_url = urlparse(request.url_root) # # # Force HTTPS if needed # scheme = 'https' if parsed_url.scheme == 'http' else parsed_url.scheme # # # Properly reconstruct the base URL # base_url = urlunparse((scheme, parsed_url.netloc, '', '', '', '')) # # # URL that will open this item directly # item_url = f"{base_url}:{Port}/item/{id}" # qr.add_data(item_url) # qr.make(fit=True) # # item = it.get_item(id) # if not item: # return None # # img = qr.make_image(fill_color="black", back_color="white") # # # Create a unique filename using UUID # unique_id = str(uuid.uuid4()) # timestamp = time.strftime("%Y%m%d%H%M%S") # # # Still include the original name for readability but ensure uniqueness with UUID # safe_name = secure_filename(item['Name']) # filename = f"{safe_name}_{unique_id}_{timestamp}.png" # qr_path = os.path.join(app.config['QR_CODE_FOLDER'], filename) # # # # Fix the file handling - save to file object, not string # with open(qr_path, 'wb') as f: # img.save(f) # # return filename # Fix fromisoformat None value checks @app.route('/plan_booking', methods=['POST']) def plan_booking(): """ Create a new planned booking or a range of bookings """ if 'username' not in session: return {"success": False, "error": "Not authenticated"}, 401 try: # Extract form data item_id = html.escape(request.form.get('item_id')) start_date_str = html.escape(request.form.get('booking_date')) # Changed from start_date to booking_date end_date_str = html.escape(request.form.get('booking_end_date')) # Changed from end_date to booking_end_date period_start = html.escape(request.form.get('period_start')) period_end = html.escape(request.form.get('period_end')) notes = html.escape(request.form.get('notes', '')) booking_type = html.escape(request.form.get('booking_type', 'single')) # Validate inputs if not all([item_id, start_date_str, end_date_str, period_start]): return {"success": False, "error": "Missing required fields"}, 400 # Parse dates try: if start_date_str: start_date = datetime.datetime.fromisoformat(start_date_str) else: return {"success": False, "error": "Missing start date"}, 400 if end_date_str: end_date = datetime.datetime.fromisoformat(end_date_str) else: return {"success": False, "error": "Missing end date"}, 400 # For single day bookings, use the start date as the end date if booking_type == 'single': end_date = start_date except ValueError as e: return {"success": False, "error": f"Invalid date format: {e}"}, 400 # Check if item exists item = it.get_item(item_id) if not item: return {"success": False, "error": "Item not found"}, 404 # Check if item is reservable if not item.get('Reservierbar', True): return {"success": False, "error": "Dieses Item kann nicht reserviert werden."}, 400 # Handle period range periods = [] if period_start: period_start_num = int(period_start) else: period_start_num = 1 # Default if None # If period_end is provided, it's a range of periods if period_end: period_end_num = int(period_end) # Validate period range if period_end_num < period_start_num: return {"success": False, "error": "End period cannot be before start period"}, 400 # Create list of all periods in the range periods = list(range(period_start_num, period_end_num + 1)) else: # Single period booking periods = [period_start_num] # For date range bookings, we'll process each date separately booking_ids = [] errors = [] # If it's a range of days if booking_type == 'range' and start_date != end_date: current_date = start_date while current_date <= end_date: # For each day in the range day_booking_ids, day_errors = process_day_bookings( item_id, current_date, periods, notes ) booking_ids.extend(day_booking_ids) errors.extend(day_errors) # Move to next day current_date += datetime.timedelta(days=1) else: # Single day with multiple periods booking_ids, errors = process_day_bookings( item_id, start_date, periods, notes ) # Return results if errors: if booking_ids: # Some succeeded, some failed return { "success": True, "partial": True, "booking_ids": booking_ids, "errors": errors } else: # All failed return {"success": False, "errors": errors}, 400 else: # All succeeded return {"success": True, "booking_ids": booking_ids} except Exception as e: import traceback print(f"Error in plan_booking: {e}") traceback.print_exc() return {"success": False, "error": f"Serverfehler: {str(e)}"}, 500 def process_day_bookings(item_id, booking_date, periods, notes): """ Helper function to process bookings for a single day across multiple periods Args: item_id: The item to book booking_date: The date for the booking periods: List of period numbers to book notes: Booking notes Returns: tuple: (list of booking_ids, list of errors) """ booking_ids = [] errors = [] for period in periods: # Get period times period_times = get_period_times(booking_date, period) if not period_times: errors.append(f"Invalid period {period}") continue # Create the start and end times for this period start_time = period_times.get('start') end_time = period_times.get('end') # Check for conflicts if au.check_booking_conflict(item_id, start_time, end_time, period): errors.append(f"Conflict for period {period} on {booking_date.strftime('%Y-%m-%d')}") continue # Create the booking booking_id = au.add_planned_booking( item_id, session['username'], start_time, end_time, notes, period=period ) if booking_id: booking_ids.append(str(booking_id)) else: errors.append(f"Failed to create booking for period {period}") return booking_ids, errors @app.route('/add_booking', methods=['POST']) def add_booking(): if 'username' not in session: return jsonify({'success': False, 'error': 'Not logged in'}) item_id = html.escape(request.form.get('item_id')) # Check if item exists and is reservable item = it.get_item(item_id) if not item: return jsonify({'success': False, 'error': 'Item not found'}) if not item.get('Reservierbar', True): return jsonify({'success': False, 'error': 'Dieses Item kann nicht reserviert werden.'}) start_date_str = request.form.get('start_date') end_date_str = request.form.get('end_date') period = request.form.get('period') notes = request.form.get('notes', '') # Parse dates as naive datetime objects try: # Simple datetime parsing without timezone if start_date_str: start_date = datetime.datetime.strptime(start_date_str, '%Y-%m-%d %H:%M:%S') else: return jsonify({'success': False, 'error': 'Missing start date'}) if end_date_str: end_date = datetime.datetime.strptime(end_date_str, '%Y-%m-%d %H:%M:%S') else: end_date = None # Continue with adding the booking booking_id = au.add_planned_booking( item_id=item_id, user=session['username'], start_date=start_date, end_date=end_date, notes=notes, period=period ) return jsonify({'success': True, 'booking_id': str(booking_id)}) except Exception as e: return jsonify({'success': False, 'error': str(e)}) @app.route('/cancel_booking/', methods=['POST']) def cancel_booking(id): """ Cancel a planned booking """ 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') return redirect(url_for('login')) # Get the booking booking = au.get_booking(id) if not booking: return {"success": False, "error": "Booking not found"}, 404 # Check if user owns this booking if booking.get('User') != session['username'] and not us.check_admin(session['username']): return {"success": False, "error": "Not authorized to cancel this booking"}, 403 # Cancel the booking result = au.cancel_booking(id) if result: return {"success": True} else: return {"success": False, "error": "Failed to cancel booking"} @app.route('/terminplan', methods=['GET']) def terminplan(): """ Route to display the booking calendar """ try: 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') return redirect(url_for('login')) # Make sure the template exists template_path = os.path.join(BASE_DIR, 'templates', 'terminplan.html') if not os.path.exists(template_path): print(f"Template file not found: {template_path}") flash('Vorlage nicht gefunden. Bitte kontaktieren Sie den Administrator.', 'error') return redirect(url_for('home')) return render_template('terminplan.html', school_periods=SCHOOL_PERIODS) except Exception as e: import traceback print(f"Error rendering terminplan: {e}") traceback.print_exc() flash('Ein Fehler ist beim Anzeigen des Kalenders aufgetreten.', 'error') return redirect(url_for('home')) '''-------------------------------------------------------------------------------------------------------------ADMIN ROUTES------------------------------------------------------------------------------------------------------------------''' @app.route('/register', methods=['GET', 'POST']) def register(): """ User registration route.false Returns: flask.Response: Rendered template 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') return redirect(url_for('login')) if not us.check_admin(session['username']): flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') return redirect(url_for('login')) if 'username' in session and us.check_admin(session['username']): if request.method == 'POST': username = request.form['username'] password = request.form['password'] name = request.form['name'] last_name = request.form['last-name'] is_student = bool(request.form.get('is_student')) if cfg.STUDENT_CARDS_MODULE_ENABLED else False student_card_id = us.normalize_student_card_id(request.form.get('student_card_id')) if cfg.STUDENT_CARDS_MODULE_ENABLED else '' max_borrow_days_raw = request.form.get('max_borrow_days') if cfg.STUDENT_CARDS_MODULE_ENABLED else None if not username or not password: flash('Bitte füllen Sie alle Felder aus', 'error') return redirect(url_for('register')) if us.get_user(username): flash('Benutzer existiert bereits', 'error') return redirect(url_for('register')) if not us.check_password_strength(password): flash('Passwort ist zu schwach', 'error') return redirect(url_for('register')) max_borrow_days = None if is_student: if not student_card_id: flash('Für Schüler muss eine Ausweis-ID angegeben werden.', 'error') return redirect(url_for('register')) if us.student_card_exists(student_card_id): flash('Die Ausweis-ID ist bereits vergeben.', 'error') return redirect(url_for('register')) try: max_borrow_days = int(max_borrow_days_raw) if max_borrow_days_raw else cfg.STUDENT_DEFAULT_BORROW_DAYS except (TypeError, ValueError): max_borrow_days = cfg.STUDENT_DEFAULT_BORROW_DAYS max_borrow_days = max(1, min(max_borrow_days, cfg.STUDENT_MAX_BORROW_DAYS)) us.add_user( username, password, name, last_name, is_student=is_student, student_card_id=student_card_id if is_student else None, max_borrow_days=max_borrow_days ) return redirect(url_for('home')) return render_template( 'register.html', library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED, student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS, student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS ) flash('Sie sind nicht berechtigt, diese Seite anzuzeigen', 'error') return redirect(url_for('login')) @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') return redirect(url_for('login')) if not us.check_admin(session['username']): flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') return redirect(url_for('login')) 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']: try: name = us.get_name(username) last_name = us.get_last_name(username) if name and last_name: fullname = f"{name} {last_name}" elif name: fullname = name elif last_name: fullname = last_name else: fullname = None except: name = "" last_name = "" fullname = None users_list.append({ 'username': username, 'admin': user.get('Admin', False), 'fullname': fullname, 'name': name, 'last_name': last_name }) return render_template( 'user_del.html', users=users_list, library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED ) @app.route('/delete_user', methods=['POST']) def delete_user(): """ Process user deletion request. Deletes a specified user from the system. 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') return redirect(url_for('login')) if not us.check_admin(session['username']): flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') return redirect(url_for('login')) 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']: 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] ausleihungen = db['ausleihungen'] items_col = db['items'] now = datetime.datetime.now() # Complete all active borrowings of this user ausleihungen.update_many( {'User': username, 'Status': 'active'}, {'$set': {'Status': 'completed', 'End': now, 'LastUpdated': now}} ) # Cancel all planned borrowings of this user ausleihungen.update_many( {'User': username, 'Status': 'planned'}, {'$set': {'Status': 'cancelled', 'LastUpdated': now}} ) # Free all items currently associated with this user items_col.update_many( {'User': username}, {'$set': {'Verfuegbar': True, 'LastUpdated': now}, '$unset': {'User': ""}} ) client.close() except Exception as e: flash(f'Warnung: Ausleihungen/Reservierungen für {username} konnten nicht vollständig zurückgesetzt werden: {str(e)}', 'warning') # Delete the user try: us.delete_user(username) flash(f'Benutzer {username} erfolgreich gelöscht', 'success') except Exception as e: flash(f'Fehler beim Löschen des Benutzers: {str(e)}', 'error') return redirect(url_for('user_del')) @app.route('/admin/borrowings') def admin_borrowings(): """ Admin view: list all active and planned borrowings with ability to reset. """ if 'username' not in session or not us.check_admin(session['username']): flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') return redirect(url_for('login')) client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] ausleihungen = db['ausleihungen'] items_col = db['items'] # Load active and planned borrowings records = list(ausleihungen.find({'Status': {'$in': ['active', 'planned']}}).sort('Start', -1)) def fmt_dt(dt): try: return dt.strftime('%d.%m.%Y %H:%M') if dt else '' except Exception: return str(dt) if dt else '' def fmt_money(value): return _format_money_value(value) entries = [] for r in records: it_id = r.get('Item') item_doc = it.get_item(it_id) invoice_data = r.get('InvoiceData') or {} try: item_code = item_doc.get('Code_4') item_name = item_doc.get('Name') item_cost = item_doc.get('Anschaffungskosten') condition_value = str(item_doc.get('Condition', '')).strip().lower() has_damage = bool(item_doc.get('HasDamage')) or condition_value == 'destroyed' or bool(item_doc.get('DamageReports')) except: item_code = None item_name = None item_cost = None has_damage = False entries.append({ 'id': str(r.get('_id')), 'item_id': str(item_doc.get('_id')) if item_doc and item_doc.get('_id') else str(it_id or ''), 'item_code': str(item_code) if item_code is not None else '', 'item_name': str(item_name or ''), 'item_cost': fmt_money(item_cost), 'item_cost_raw': item_cost if item_cost is not None else '', 'user': r.get('User', ''), 'status': r.get('Status', ''), 'start': fmt_dt(r.get('Start')), 'end': fmt_dt(r.get('End')), 'period': r.get('Period') if r.get('Period') is not None else '', 'notes': r.get('Notes', ''), 'invoice_number': invoice_data.get('invoice_number', ''), 'invoice_amount': fmt_money(invoice_data.get('amount')) if invoice_data.get('amount') is not None else fmt_money(item_cost), 'invoice_reason': invoice_data.get('damage_reason', ''), 'invoice_created_at': fmt_dt(invoice_data.get('created_at')) if isinstance(invoice_data.get('created_at'), datetime.datetime) else '', 'invoice_paid': bool(invoice_data.get('paid', False)), 'invoice_paid_at': fmt_dt(invoice_data.get('paid_at')) if isinstance(invoice_data.get('paid_at'), datetime.datetime) else '', 'has_damage': has_damage, }) client.close() return render_template( 'admin_borrowings.html', entries=entries, library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED ) @app.route('/admin/reset_borrowing/', methods=['POST']) def admin_reset_borrowing(borrow_id): """ Admin action: reset a single borrowing. - If active: complete it and free the item - If planned: cancel it """ if 'username' not in session or not us.check_admin(session['username']): flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') return redirect(url_for('login')) try: client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] ausleihungen = db['ausleihungen'] items_col = db['items'] rec = ausleihungen.find_one({'_id': ObjectId(borrow_id)}) if not rec: client.close() flash('Ausleihung nicht gefunden', 'error') return redirect(url_for('admin_borrowings')) status = rec.get('Status') item_id = rec.get('Item') user = rec.get('User') now = datetime.datetime.now() if status == 'active': ausleihungen.update_one({'_id': rec['_id']}, {'$set': {'Status': 'completed', 'End': now, 'LastUpdated': now}}) # Free the item if item_id: try: items_col.update_one({'_id': ObjectId(item_id)}, {'$set': {'Verfuegbar': True, 'LastUpdated': now}, '$unset': {'User': ""}}) except Exception: pass flash('Aktive Ausleihe wurde zurückgesetzt (abgeschlossen).', 'success') elif status == 'planned': ausleihungen.update_one({'_id': rec['_id']}, {'$set': {'Status': 'cancelled', 'LastUpdated': now}}) flash('Geplante Ausleihe wurde storniert.', 'success') else: flash('Diese Ausleihe ist weder aktiv noch geplant.', 'warning') client.close() except Exception as e: flash(f'Fehler beim Zurücksetzen: {str(e)}', 'error') return redirect(url_for('admin_borrowings')) @app.route('/admin/borrowings//invoice', methods=['POST']) def admin_create_invoice(borrow_id): """Create a PDF invoice for a destroyed borrowed item.""" if 'username' not in session or not us.check_admin(session['username']): flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') return redirect(url_for('login')) client = None try: client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] ausleihungen = db['ausleihungen'] items_col = db['items'] borrow_doc = ausleihungen.find_one({'_id': ObjectId(borrow_id)}) if not borrow_doc: flash('Ausleihung nicht gefunden.', 'error') return redirect(url_for('admin_borrowings')) item_id = borrow_doc.get('Item') item_doc = None if item_id: try: item_doc = items_col.find_one({'_id': ObjectId(item_id)}) except Exception: item_doc = items_col.find_one({'_id': item_id}) if not item_doc: flash('Zugehöriges Element nicht gefunden.', 'error') return redirect(url_for('admin_borrowings')) if borrow_doc.get('Status') not in ['active', 'completed'] and not borrow_doc.get('InvoiceData'): flash('Eine Rechnung kann nur für aktive oder bereits dokumentierte Ausleihungen erstellt werden.', 'warning') return redirect(url_for('admin_borrowings')) amount_raw = request.form.get('invoice_amount') or request.form.get('amount') or item_doc.get('Anschaffungskosten') amount_value = _parse_money_value(amount_raw) if amount_value is None or amount_value <= 0: flash('Bitte einen gültigen Rechnungsbetrag angeben.', 'error') return redirect(url_for('admin_borrowings')) damage_reason = str(request.form.get('damage_reason', '')).strip() or 'Das ausgeliehene Element wurde beschädigt oder zerstört.' if len(damage_reason) > 2000: flash('Die Schadensbeschreibung ist zu lang.', 'error') return redirect(url_for('admin_borrowings')) mark_destroyed = request.form.get('mark_destroyed') == 'on' close_borrowing = request.form.get('close_borrowing') == 'on' now = datetime.datetime.now() existing_invoice = borrow_doc.get('InvoiceData') or {} invoice_number = existing_invoice.get('invoice_number') or _build_invoice_number(borrow_doc['_id'], now) borrower = borrow_doc.get('User', '') item_name = item_doc.get('Name', '') item_code = item_doc.get('Code_4', '') invoice_data = { 'invoice_number': invoice_number, 'amount': round(amount_value, 2), 'amount_text': _format_money_value(amount_value), 'damage_reason': damage_reason, 'created_at': now, 'created_by': session.get('username', ''), 'borrower': borrower, 'item_id': str(item_doc['_id']), 'item_name': item_name, 'item_code': item_code, 'mark_destroyed': mark_destroyed, 'status_before_invoice': borrow_doc.get('Status', ''), 'paid': False, 'paid_at': None, 'paid_by': '', } update_fields = { 'InvoiceData': invoice_data, 'LastUpdated': now, } if close_borrowing: update_fields['Status'] = 'completed' update_fields['End'] = now ausleihungen.update_one({'_id': borrow_doc['_id']}, {'$set': update_fields}) if mark_destroyed: damage_entry = { 'description': damage_reason, 'reported_by': session['username'], 'reported_at': now, 'invoice_number': invoice_number, 'invoice_amount': round(amount_value, 2), 'source': 'invoice' } items_col.update_one( {'_id': item_doc['_id']}, { '$push': {'DamageReports': {'$each': [damage_entry], '$position': 0}}, '$set': { 'HasDamage': True, 'Verfuegbar': False, 'LastUpdated': now, 'Condition': 'destroyed' }, '$unset': {'User': ''} } ) try: logs_collection = db['system_logs'] logs_collection.insert_one({ 'type': 'damage_invoice', 'timestamp': now.isoformat(), 'user': session.get('username'), 'borrow_id': borrow_id, 'item_id': str(item_doc['_id']), 'item_name': item_name, 'invoice_number': invoice_number, 'amount': round(amount_value, 2), 'ip': request.remote_addr, }) except Exception as log_err: app.logger.warning(f"Damage invoice log write failed for borrow {borrow_id}: {log_err}") pdf_buffer = _build_invoice_pdf(invoice_data) return send_file( pdf_buffer, mimetype='application/pdf', as_attachment=True, download_name=f'rechnung_{invoice_number}.pdf' ) except Exception as e: app.logger.error(f"Error creating damage invoice for borrow {borrow_id}: {e}") flash('Fehler beim Erstellen der Rechnung.', 'error') return redirect(url_for('admin_borrowings')) finally: if client: client.close() @app.route('/admin/borrowings//invoice/mark-paid', methods=['POST']) def admin_mark_invoice_paid(borrow_id): """Mark an existing invoice as paid.""" if 'username' not in session or not us.check_admin(session['username']): flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') return redirect(url_for('login')) client = None try: client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] ausleihungen = db['ausleihungen'] borrow_doc = ausleihungen.find_one({'_id': ObjectId(borrow_id)}, {'InvoiceData': 1}) if not borrow_doc: flash('Ausleihung nicht gefunden.', 'error') return redirect(url_for('admin_borrowings')) invoice_data = borrow_doc.get('InvoiceData') or {} if not invoice_data: flash('Für diese Ausleihung existiert keine Rechnung.', 'warning') return redirect(url_for('admin_borrowings')) if invoice_data.get('paid') is True: flash('Rechnung ist bereits als bezahlt markiert.', 'info') return redirect(url_for('admin_borrowings')) now = datetime.datetime.now() result = ausleihungen.update_one( {'_id': borrow_doc['_id']}, { '$set': { 'InvoiceData.paid': True, 'InvoiceData.paid_at': now, 'InvoiceData.paid_by': session.get('username', ''), 'LastUpdated': now, } } ) if result.matched_count == 0: flash('Ausleihung nicht gefunden.', 'error') return redirect(url_for('admin_borrowings')) try: logs_collection = db['system_logs'] logs_collection.insert_one({ 'type': 'damage_invoice_paid', 'timestamp': now.isoformat(), 'user': session.get('username'), 'borrow_id': borrow_id, 'invoice_number': invoice_data.get('invoice_number', ''), 'amount': invoice_data.get('amount'), 'ip': request.remote_addr, }) except Exception as log_err: app.logger.warning(f"Damage invoice paid log write failed for borrow {borrow_id}: {log_err}") flash('Rechnung wurde als bezahlt markiert.', 'success') return redirect(url_for('admin_borrowings')) except Exception as e: app.logger.error(f"Error marking invoice paid for borrow {borrow_id}: {e}") flash('Fehler beim Markieren als bezahlt.', 'error') return redirect(url_for('admin_borrowings')) finally: if client: client.close() @app.route('/admin/borrowings//invoice/finalize', methods=['POST']) def admin_finalize_invoice_and_repair(borrow_id): """Mark invoice as paid and item as repaired in one action.""" if 'username' not in session or not us.check_admin(session['username']): flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') return redirect(url_for('login')) client = None try: client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] ausleihungen = db['ausleihungen'] items_col = db['items'] borrow_doc = ausleihungen.find_one({'_id': ObjectId(borrow_id)}) if not borrow_doc: flash('Ausleihung nicht gefunden.', 'error') return redirect(url_for('admin_borrowings')) invoice_data = borrow_doc.get('InvoiceData') or {} if not invoice_data: flash('Für diese Ausleihung existiert keine Rechnung.', 'warning') return redirect(url_for('admin_borrowings')) item_doc = None item_id = borrow_doc.get('Item') if item_id: try: item_doc = items_col.find_one({'_id': ObjectId(item_id)}) except Exception: item_doc = items_col.find_one({'_id': item_id}) now = datetime.datetime.now() update_fields = { 'LastUpdated': now, } if invoice_data.get('paid') is not True: update_fields.update({ 'InvoiceData.paid': True, 'InvoiceData.paid_at': now, 'InvoiceData.paid_by': session.get('username', ''), }) if borrow_doc.get('Status') == 'active': update_fields['Status'] = 'completed' update_fields['End'] = now ausleihungen.update_one({'_id': borrow_doc['_id']}, {'$set': update_fields}) repaired = False resolved_count = 0 if item_doc: open_reports = item_doc.get('DamageReports', []) or [] resolved_count = len(open_reports) item_update = { '$set': { 'DamageReports': [], 'HasDamage': False, 'Verfuegbar': True, 'LastUpdated': now, }, '$unset': { 'User': '', 'Condition': '', }, } if open_reports: repair_entry = { 'repaired_by': session['username'], 'repaired_at': now, 'resolved_reports': open_reports, } item_update['$push'] = {'DamageRepairs': {'$each': [repair_entry], '$position': 0}} item_result = items_col.update_one({'_id': item_doc['_id']}, item_update) repaired = item_result.matched_count > 0 try: logs_collection = db['system_logs'] logs_collection.insert_one({ 'type': 'damage_invoice_finalize', 'timestamp': now.isoformat(), 'user': session.get('username'), 'borrow_id': borrow_id, 'item_id': str(item_doc.get('_id')) if item_doc else '', 'invoice_number': invoice_data.get('invoice_number', ''), 'amount': invoice_data.get('amount'), 'repaired': repaired, 'resolved_damage_reports': resolved_count, 'ip': request.remote_addr, }) except Exception as log_err: app.logger.warning(f"Damage invoice finalize log write failed for borrow {borrow_id}: {log_err}") if repaired: flash('Rechnung als bezahlt markiert und Element als repariert abgeschlossen.', 'success') else: flash('Rechnung als bezahlt markiert. Element konnte nicht repariert werden (nicht gefunden).', 'warning') return redirect(url_for('admin_borrowings')) except Exception as e: app.logger.error(f"Error finalizing invoice/repair for borrow {borrow_id}: {e}") flash('Fehler beim Kombinieren von bezahlt und repariert.', 'error') return redirect(url_for('admin_borrowings')) finally: if client: client.close() @app.route('/admin/borrowings//invoice/pdf', methods=['GET']) def admin_view_invoice_pdf(borrow_id): """View a previously created invoice PDF for a borrowing.""" if 'username' not in session or not us.check_admin(session['username']): flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') return redirect(url_for('login')) client = None try: client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] ausleihungen = db['ausleihungen'] items_col = db['items'] borrow_doc = ausleihungen.find_one({'_id': ObjectId(borrow_id)}, {'InvoiceData': 1, 'Item': 1, 'User': 1}) if not borrow_doc: flash('Ausleihung nicht gefunden.', 'error') return redirect(url_for('library_loans_admin')) invoice_data = borrow_doc.get('InvoiceData') or {} if not invoice_data: flash('Für diese Ausleihe wurde noch keine Rechnung erstellt.', 'warning') return redirect(url_for('library_loans_admin')) item_doc = None item_id = borrow_doc.get('Item') if item_id: try: item_doc = items_col.find_one({'_id': ObjectId(item_id)}) except Exception: item_doc = items_col.find_one({'_id': item_id}) pdf_payload = _prepare_invoice_pdf_payload(invoice_data, borrow_doc=borrow_doc, item_doc=item_doc) invoice_number = pdf_payload.get('invoice_number', 'rechnung') pdf_buffer = _build_invoice_pdf(pdf_payload) return send_file( pdf_buffer, mimetype='application/pdf', as_attachment=False, download_name=f'rechnung_{invoice_number}.pdf' ) except Exception as e: app.logger.error(f"Error loading stored invoice PDF for borrow {borrow_id}: {e}") flash('Fehler beim Öffnen der Rechnung.', 'error') return redirect(url_for('library_loans_admin')) finally: if client: client.close() @app.route('/admin/library/items//invoices', methods=['GET']) def library_item_invoices(item_id): """Show all stored invoices for one specific library item.""" if 'username' not in session or not us.check_admin(session['username']): flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') return redirect(url_for('login')) if not cfg.LIBRARY_MODULE_ENABLED: flash('Bibliotheks-Modul ist deaktiviert.', 'error') return redirect(url_for('home_admin')) client = None try: client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] items_col = db['items'] ausleihungen = db['ausleihungen'] try: item_doc = items_col.find_one({'_id': ObjectId(item_id)}) except Exception: item_doc = items_col.find_one({'_id': item_id}) if not item_doc: flash('Bibliotheksmedium nicht gefunden.', 'error') return redirect(url_for('library_loans_admin')) borrow_docs = list(ausleihungen.find( { 'Item': str(item_doc.get('_id')), 'InvoiceData': {'$exists': True, '$ne': {}} }, { 'Status': 1, 'User': 1, 'Start': 1, 'End': 1, 'InvoiceData': 1, } ).sort('InvoiceData.created_at', -1)) entries = [] for borrow_doc in borrow_docs: invoice_data = borrow_doc.get('InvoiceData') or {} created_at = invoice_data.get('created_at') created_at_display = created_at.strftime('%d.%m.%Y %H:%M') if isinstance(created_at, datetime.datetime) else (str(created_at) if created_at else '') paid_at = invoice_data.get('paid_at') paid_at_display = paid_at.strftime('%d.%m.%Y %H:%M') if isinstance(paid_at, datetime.datetime) else (str(paid_at) if paid_at else '') entries.append({ 'borrow_id': str(borrow_doc.get('_id')), 'borrow_status': borrow_doc.get('Status', ''), 'borrow_user': borrow_doc.get('User', ''), 'borrow_start': borrow_doc.get('Start').strftime('%d.%m.%Y %H:%M') if isinstance(borrow_doc.get('Start'), datetime.datetime) else '', 'borrow_end': borrow_doc.get('End').strftime('%d.%m.%Y %H:%M') if isinstance(borrow_doc.get('End'), datetime.datetime) else '', 'invoice_number': invoice_data.get('invoice_number', ''), 'invoice_amount': _format_money_value(invoice_data.get('amount')), 'invoice_reason': invoice_data.get('damage_reason', ''), 'invoice_created_at': created_at_display, 'invoice_created_by': invoice_data.get('created_by', ''), 'invoice_paid': bool(invoice_data.get('paid', False)), 'invoice_paid_at': paid_at_display, 'invoice_paid_by': invoice_data.get('paid_by', ''), }) return render_template( 'library_item_invoices.html', item={ 'id': str(item_doc.get('_id')), 'name': item_doc.get('Name', ''), 'code': item_doc.get('Code_4', ''), 'author': item_doc.get('Author', ''), 'isbn': item_doc.get('ISBN', ''), }, invoices=entries, library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED, ) except Exception as e: app.logger.error(f"Error loading invoice history for item {item_id}: {e}") flash('Fehler beim Laden der Rechnungshistorie.', 'error') return redirect(url_for('library_loans_admin')) finally: if client: client.close() @app.route('/admin_reset_user_password', methods=['POST']) def admin_reset_user_password(): """ Admin route to reset a user's password. Resets the password for the specified user to a temporary password. Only accessible by administrators. Returns: flask.Response: Redirect to user management page with status message """ if 'username' not in session: flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adresse zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') return redirect(url_for('login')) if not us.check_admin(session['username']): flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adresse zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') return redirect(url_for('login')) username = request.form.get('username') new_password = html.escape(request.form.get('new_password', 'Password123')) # Default temporary password if not username: flash('Kein Benutzer ausgewählt', 'error') return redirect(url_for('user_del')) # Check if user exists user = us.get_user(username) if not user: flash(f'Benutzer {username} nicht gefunden', 'error') return redirect(url_for('user_del')) # Prevent changing own password through this route (use change_password instead) if username == session['username']: flash('Sie können Ihr eigenes Passwort nicht über diese Funktion ändern. Bitte verwenden Sie dafür die Option "Passwort ändern" im Profil-Menü.', 'error') return redirect(url_for('user_del')) # Reset the password try: us.update_password(username, new_password) flash(f'Passwort für {username} wurde erfolgreich zurückgesetzt auf: {new_password}', 'success') except Exception as e: flash(f'Fehler beim Zurücksetzen des Passworts: {str(e)}', 'error') return redirect(url_for('user_del')) @app.route('/admin_update_user_name', methods=['POST']) def admin_update_user_name(): """ Admin route to update a user's name details. Returns: flask.Response: Redirect to user management page """ if 'username' not in session or not us.check_admin(session['username']): flash('Nicht autorisierter Zugriff', 'error') return redirect(url_for('login')) username = request.form.get('username') name = html.escape(request.form.get('name')) last_name = html.escape(request.form.get('last_name')) if not username: flash('Kein Benutzer ausgewählt', 'error') return redirect(url_for('user_del')) if us.update_user_name(username, name, last_name): flash(f'Name für {username} wurde erfolgreich aktualisiert.', 'success') else: flash(f'Fehler beim Aktualisieren des Namens.', 'error') return redirect(url_for('user_del')) @app.route('/logs') def logs(): """ View system logs interface. Displays a history of all item borrowings with detailed information. Returns: flask.Response: Rendered template with logs or redirect if not authenticated """ 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') return redirect(url_for('login')) if not us.check_admin(session['username']): flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') return redirect(url_for('login')) # Get ausleihungen all_ausleihungen = au.get_ausleihungen() formatted_items = [] for ausleihung in all_ausleihungen: try: # Get item details - from sample data, Item is an ID item = it.get_item(ausleihung.get('Item')) item_name = item.get('Name', 'Unknown Item') if item else 'Unknown Item' # Get user details - from sample data, User is a username string username = ausleihung.get('User', 'Unknown User') # Determine (verified) status for display try: display_status = au.get_current_status(ausleihung) except Exception: display_status = ausleihung.get('Status', 'unknown') # Format dates for display start_date = ausleihung.get('Start') if isinstance(start_date, datetime.datetime): start_date = start_date.strftime('%Y-%m-%d %H:%M') end_date = ausleihung.get('End', 'Not returned') if isinstance(end_date, datetime.datetime): end_date = end_date.strftime('%Y-%m-%d %H:%M') # Calculate duration duration = 'N/A' if isinstance(ausleihung.get('Start'), datetime.datetime) and isinstance(ausleihung.get('End'), datetime.datetime): duration_td = ausleihung['End'] - ausleihung['Start'] hours, remainder = divmod(duration_td.seconds, 3600) minutes, _ = divmod(remainder, 60) if duration_td.days > 0: duration = f"{duration_td.days}d {hours}h {minutes}m" else: duration = f"{hours}h {minutes}m" formatted_items.append({ 'Item': item_name, 'User': username, 'Start': start_date, 'End': end_date, 'Duration': duration, 'Status': display_status, 'EventType': 'Ausleihe', 'id': str(ausleihung['_id']), 'ConflictDetected': ausleihung.get('ConflictDetected', False), 'ConflictNote': ausleihung.get('ConflictNote', ''), }) except Exception as e: continue # Add damage + repair entries from system_logs try: client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] logs_collection = db['system_logs'] extra_logs = list(logs_collection.find({'type': {'$in': ['damage_report', 'damage_repair']}})) for log_item in extra_logs: log_type = log_item.get('type', '') item_id = log_item.get('item_id') item_obj = it.get_item(item_id) if item_id else None item_name = item_obj.get('Name', 'Unknown Item') if item_obj else 'Unknown Item' ts_raw = log_item.get('timestamp') ts_display = ts_raw or '-' if isinstance(ts_raw, datetime.datetime): ts_display = ts_raw.strftime('%Y-%m-%d %H:%M') elif isinstance(ts_raw, str): try: ts_display = datetime.datetime.fromisoformat(ts_raw).strftime('%Y-%m-%d %H:%M') except Exception: ts_display = ts_raw if log_type == 'damage_report': note = log_item.get('note', '') formatted_items.append({ 'Item': item_name, 'User': log_item.get('user', 'Unknown User'), 'Start': ts_display, 'End': '-', 'Duration': '-', 'Status': 'gemeldet', 'EventType': 'Schaden', 'id': str(log_item.get('_id', '')), 'ConflictDetected': False, 'ConflictNote': note, }) elif log_type == 'damage_repair': resolved_count = log_item.get('resolved_count', 0) formatted_items.append({ 'Item': item_name, 'User': log_item.get('user', 'Unknown User'), 'Start': ts_display, 'End': '-', 'Duration': '-', 'Status': 'repariert', 'EventType': 'Reparatur', 'id': str(log_item.get('_id', '')), 'ConflictDetected': False, 'ConflictNote': f'Erledigte Schäden: {resolved_count}', }) client.close() except Exception as e: app.logger.warning(f"Could not load damage/repair logs: {e}") def parse_sort_date(value): if isinstance(value, datetime.datetime): return value if isinstance(value, str): for fmt in ('%Y-%m-%d %H:%M', '%Y-%m-%d'): try: return datetime.datetime.strptime(value, fmt) except Exception: continue try: return datetime.datetime.fromisoformat(value) except Exception: return datetime.datetime.min return datetime.datetime.min formatted_items.sort(key=lambda entry: parse_sort_date(entry.get('Start')), reverse=True) return render_template( 'logs.html', items=formatted_items, library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED ) @app.route('/get_logs', methods=['GET']) def get_logs(): """ API endpoint to retrieve all borrowing logs. Returns: dict: Dictionary containing all borrowing records or redirect if not authenticated """ if not session.get('username'): return redirect(url_for('login')) logs = au.get_ausleihungen() return logs @app.route('/get_usernames', methods=['GET']) def get_usernames(): """ API endpoint to retrieve all usernames from the system. Requires administrator privileges. Returns: dict: Dictionary containing all users or redirect if not authenticated """ 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') return redirect(url_for('login')) if not us.check_admin(session['username']): flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') return redirect(url_for('logout')) elif 'username' in session and us.check_admin(session['username']): return jsonify(us.get_all_users()) # Fixed to use get_all_users else: flash('Bitte melden Sie sich an, um auf diese Funktion zuzugreifen', 'error') return redirect(url_for('login')) # Added proper return # New routes for filter management @app.route('/manage_filters') def manage_filters(): """ " Admin page to manage predefined filter values. Returns: flask.Response: Rendered filter management template 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') return redirect(url_for('login')) if not us.check_admin(session['username']): flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') return redirect(url_for('login')) # Get predefined filter values filter1_values = it.get_predefined_filter_values(1) filter2_values = it.get_predefined_filter_values(2) return render_template( 'manage_filters.html', filter1_values=filter1_values, filter2_values=filter2_values, library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED ) @app.route('/add_filter_value/', methods=['POST']) def add_filter_value(filter_num): """ Add a new predefined value to the specified filter. Args: filter_num (int): Filter number (1 or 2) Returns: flask.Response: Redirect to filter management page """ if 'username' not in session or not us.check_admin(session['username']): return jsonify({'success': False, 'error': 'Not authorized'}), 403 value = sanitize_form_value(request.form.get('value')) if not value: flash('Bitte geben Sie einen Wert ein', 'error') return redirect(url_for('manage_filters')) # Add the value to the filter success = it.add_predefined_filter_value(filter_num, value) if success: flash(f'Wert "{value}" wurde zu Filter {filter_num} hinzugefügt', 'success') else: flash(f'Wert "{value}" existiert bereits in Filter {filter_num}', 'error') return redirect(url_for('manage_filters')) @app.route('/remove_filter_value//', methods=['POST']) def remove_filter_value(filter_num, value): """ Remove a predefined value from the specified filter. Args: filter_num (int): Filter number (1 or 2) value (str): Value to remove Returns: flask.Response: Redirect to filter management page """ if 'username' not in session or not us.check_admin(session['username']): return jsonify({'success': False, 'error': 'Not authorized'}), 403 # Remove the value from the filter success = it.remove_predefined_filter_value(filter_num, value) if success: flash(f'Wert "{value}" wurde aus Filter {filter_num} entfernt', 'success') else: flash(f'Fehler beim Entfernen des Wertes "{value}" aus Filter {filter_num}', 'error') return redirect(url_for('manage_filters')) @app.route('/get_predefined_filter_values/') def get_predefined_filter_values(filter_num): """ API endpoint to get predefined values for a specific filter. Args: filter_num (int): Filter number (1 or 2) Returns: dict: Dictionary containing predefined filter values """ values = it.get_predefined_filter_values(filter_num) return jsonify({'values': values}) @app.route('/search_word/') def search_word(word): """Search items by Titel (Name) and Beschreibung, case-insensitive. Returns: JSON with list of matching item IDs. """ try: term = (word or "").strip() if not term: return jsonify({"success": True, "response": []}) term_lower = term.lower() id_set = set() for i in it.get_items(): beschreibung = i.get("Beschreibung", "") titel = i.get("Name", "") # Normalize Beschreibung to string try: if isinstance(beschreibung, (list, tuple)): text = " ".join([str(x) for x in beschreibung]) else: text = str(beschreibung) except Exception: text = "" # Normalize title try: title_text = str(titel) except Exception: title_text = "" if (term_lower in text.lower()) or (term_lower in title_text.lower()): _id = i.get("_id") if _id is not None: id_set.add(str(_id)) return jsonify({"success": True, "response": list(id_set)}) except Exception as e: return jsonify({"success": False, "response": str(e)}) @app.route('/fetch_book_info/') def fetch_book_info(isbn): """ API endpoint to fetch book information by ISBN using Google Books API Args: isbn (str): ISBN to look up Returns: dict: Book information or error message """ if 'username' not in session or not us.check_admin(session['username']): return jsonify({"error": "Not authorized"}), 403 if not cfg.LIBRARY_MODULE_ENABLED: return jsonify({"error": "Bibliotheks-Modul ist deaktiviert."}), 403 try: clean_isbn = normalize_and_validate_isbn(isbn) if not clean_isbn: return jsonify({"error": "Ungültige ISBN. Bitte ISBN-10 oder ISBN-13 verwenden."}), 400 # First source: Google Books response = requests.get( f"https://www.googleapis.com/books/v1/volumes?q=isbn:{clean_isbn}", timeout=10 ) if response.status_code == 200: data = response.json() if data.get('totalItems', 0) > 0 and data.get('items'): book_info = data['items'][0].get('volumeInfo', {}) sale_info = data['items'][0].get('saleInfo', {}) price = None retail_price = sale_info.get('retailPrice', {}) list_price = sale_info.get('listPrice', {}) if retail_price and 'amount' in retail_price: price = f"{retail_price['amount']} {retail_price.get('currencyCode', '€')}" elif list_price and 'amount' in list_price: price = f"{list_price['amount']} {list_price.get('currencyCode', '€')}" thumbnail = book_info.get('imageLinks', {}).get('thumbnail', '') if thumbnail: thumbnail = thumbnail.replace('http:', 'https:') return jsonify({ "title": book_info.get('title', 'Unknown Title'), "authors": ', '.join(book_info.get('authors', ['Unknown Author'])), "publisher": book_info.get('publisher', 'Unknown Publisher'), "publishedDate": book_info.get('publishedDate', 'Unknown Date'), "description": book_info.get('description', 'No description available'), "pageCount": book_info.get('pageCount', 'Unknown'), "price": price, "thumbnail": thumbnail, "isbn": clean_isbn, "source": "google-books" }) # Fallback: OpenLibrary ol_response = requests.get( f"https://openlibrary.org/isbn/{clean_isbn}.json", timeout=10 ) if ol_response.status_code == 200: ol_data = ol_response.json() author_names = [] for author_ref in ol_data.get('authors', []): key = author_ref.get('key') if not key: continue try: author_resp = requests.get(f"https://openlibrary.org{key}.json", timeout=8) if author_resp.status_code == 200: author_names.append(author_resp.json().get('name')) except Exception: continue return jsonify({ "title": ol_data.get('title', 'Unknown Title'), "authors": ', '.join([a for a in author_names if a]) if author_names else 'Unknown Author', "publisher": ', '.join(ol_data.get('publishers', [])) if ol_data.get('publishers') else 'Unknown Publisher', "publishedDate": ol_data.get('publish_date', 'Unknown Date'), "description": (ol_data.get('description', {}).get('value') if isinstance(ol_data.get('description'), dict) else ol_data.get('description')) or 'No description available', "pageCount": ol_data.get('number_of_pages', 'Unknown'), "price": None, "thumbnail": f"https://covers.openlibrary.org/b/isbn/{clean_isbn}-L.jpg", "isbn": clean_isbn, "source": "openlibrary" }) return jsonify({"error": f"Kein Buch zu dieser ISBN gefunden: {clean_isbn}"}), 404 except Exception as e: print(f"Error fetching book data: {e}") return jsonify({"error": f"Failed to fetch book information: {str(e)}"}), 500 @app.route('/download_book_cover', methods=['POST']) def download_book_cover(): """ API endpoint to download and save a book cover image from URL Returns: dict: Success status and filename or error message """ if 'username' not in session: return jsonify({"error": "Not authorized"}), 403 if not us.check_admin(session['username']): return jsonify({"error": "Admin privileges required"}), 403 if not cfg.LIBRARY_MODULE_ENABLED: return jsonify({"error": "Bibliotheks-Modul ist deaktiviert."}), 403 try: data = request.get_json() image_url = data.get('url') if not image_url: return jsonify({"error": "No image URL provided"}), 400 # Download the image response = requests.get(image_url, stream=True, timeout=10) if response.status_code != 200: return jsonify({"error": f"Failed to download image: Status {response.status_code}"}), 400 # Check content type to ensure it's an image of allowed format content_type = response.headers.get('content-type', '') allowed_types = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'] if not any(allowed_type in content_type.lower() for allowed_type in allowed_types): return jsonify({ "error": f"Nicht unterstütztes Bildformat: {content_type}. Erlaubte Formate: JPG, JPEG, PNG, GIF" }), 400 # Generate a fully unique filename using UUID import uuid import time unique_id = str(uuid.uuid4()) timestamp = time.strftime("%Y%m%d%H%M%S") # Use appropriate extension based on content type extension = '.jpg' # default if 'image/png' in content_type.lower(): extension = '.png' elif 'image/gif' in content_type.lower(): extension = '.gif' filename = f"book_cover_{unique_id}_{timestamp}{extension}" # Save the image to uploads folder filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename) with open(filepath, 'wb') as f: for chunk in response.iter_content(chunk_size=8192): f.write(chunk) return jsonify({ "success": True, "filename": filename, "message": "Image downloaded successfully" }) except Exception as e: print(f"Error downloading book cover: {e}") return jsonify({"error": f"Failed to download image: {str(e)}"}), 500 @app.route('/proxy_image') def proxy_image(): """ Proxy endpoint to fetch images from external sources, bypassing CORS restrictions Returns: flask.Response: The image data or an error response """ url = request.args.get('url') if not url: return jsonify({"error": "No URL provided"}), 400 try: # Fetch the image from the external source response = requests.get(url, stream=True, timeout=5) # Check if the request was successful if response.status_code != 200: return jsonify({"error": f"Failed to fetch image: Status {response.status_code}"}), response.status_code # Get the content type content_type = response.headers.get('Content-Type', 'image/jpeg') # Return the image data with appropriate headers return Response( response=response.content, status=200, headers={ 'Content-Type': content_type } ) except Exception as e: print(f"Error in proxy_image: {e}") return jsonify({"error": f"Error fetching image: {str(e)}"}), 500 # Add missing get_period_times function def get_period_times(booking_date, period_num): """ Get the start and end times for a given period on a specific date Args: booking_date (datetime): The date for the booking period_num (int): The period number Returns: dict: {"start": datetime, "end": datetime} or None if invalid """ try: # Convert period_num to string for lookup in SCHOOL_PERIODS period_str = str(period_num) if period_str not in SCHOOL_PERIODS: return None period_info = SCHOOL_PERIODS[period_str] # Extract start and end times from period info start_time_str = period_info.get('start') end_time_str = period_info.get('end') if not start_time_str or not end_time_str: return None # Parse hours and minutes start_hour, start_min = map(int, start_time_str.split(':')) end_hour, end_min = map(int, end_time_str.split(':')) # Create datetime objects for start and end times start_datetime = datetime.datetime.combine( booking_date.date(), datetime.time(start_hour, start_min) ) end_datetime = datetime.datetime.combine( booking_date.date(), datetime.time(end_hour, end_min) ) return { 'start': start_datetime, 'end': end_datetime } except Exception as e: print(f"Error getting period times: {e}") return None @app.route('/my_borrowed_items') def my_borrowed_items(): """ Zeigt alle vom aktuellen Benutzer ausgeliehenen und geplanten Objekte an. Returns: Response: Gerendertes Template mit den ausgeliehenen und geplanten Objekten des Benutzers """ if 'username' not in session: flash('Bitte melden Sie sich an, um Ihre ausgeliehenen Objekte anzuzeigen', 'error') return redirect(url_for('login', next=request.path)) username = session['username'] client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] items_collection = db.items ausleihungen_collection = db.ausleihungen # Get current time for comparison current_time = datetime.datetime.now() # Check if user is admin user_is_admin = False if 'is_admin' in session: user_is_admin = session['is_admin'] # Get items currently borrowed by the user (where Verfuegbar=false and User=username) borrowed_items = list(items_collection.find({'Verfuegbar': False, 'User': username})) # Get active and planned ausleihungen for the user active_ausleihungen = list(ausleihungen_collection.find({ 'User': username, 'Status': 'active' })) planned_ausleihungen = list(ausleihungen_collection.find({ 'User': username, 'Status': 'planned', 'Start': {'$gt': current_time} })) # DEBUG: Log the number of planned appointments found app.logger.info(f"Found {len(planned_ausleihungen)} planned appointments for user {username}") for appt in planned_ausleihungen: app.logger.info(f"Planned appointment: ID={str(appt['_id'])}, Item={str(appt.get('Item'))}, Start={appt.get('Start')}") # Process items active_items = [] planned_items = [] processed_item_ids = set() # Keep track of processed item IDs to avoid duplicates # First, process items that are directly marked as borrowed by the user for item in borrowed_items: # Convert ObjectId to string for template item['_id'] = str(item['_id']) active_items.append(item) processed_item_ids.add(item['_id']) # Process active appointments for appointment in active_ausleihungen: # Get the item ID from the appointment item_id = appointment.get('Item') if not item_id or str(item_id) in processed_item_ids: continue # Skip if we already processed this item or no item ID # Get item details item_obj = items_collection.find_one({'_id': ObjectId(item_id)}) if item_obj: # Convert ObjectId to string for template item_obj['_id'] = str(item_obj['_id']) # Add appointment data item_obj['AppointmentData'] = { 'id': str(appointment['_id']), 'start': appointment.get('Start'), 'end': appointment.get('End'), 'notes': appointment.get('Notes'), 'period': appointment.get('Period'), 'status': appointment.get('VerifiedStatus', appointment.get('Status')), } # Mark that this item is part of an active appointment item_obj['ActiveAppointment'] = True # Add to the list only if not already there if str(item_obj['_id']) not in processed_item_ids: active_items.append(item_obj) processed_item_ids.add(str(item_obj['_id'])) # Process planned appointments for appointment in planned_ausleihungen: item_id = appointment.get('Item') if not item_id: continue item_obj = items_collection.find_one({'_id': ObjectId(item_id)}) if item_obj: item_obj['_id'] = str(item_obj['_id']) # Add appointment data item_obj['AppointmentData'] = { 'id': str(appointment['_id']), 'start': appointment.get('Start'), 'end': appointment.get('End'), 'notes': appointment.get('Notes'), 'period': appointment.get('Period'), 'status': appointment.get('Status'), } planned_items.append(item_obj) client.close() # DEBUG: Log what we're passing to the template app.logger.info(f"Passing {len(active_items)} active items and {len(planned_items)} planned items to template") if planned_items: for i, item in enumerate(planned_items): app.logger.info(f"Planned item {i+1}: {item['Name']}, Appointment ID: {item['AppointmentData']['id']}") return render_template( 'my_borrowed_items.html', items=active_items, planned_items=planned_items, user_is_admin=user_is_admin ) @app.route('/favicon.ico') def favicon(): """ Serve the favicon directly from the static directory. Returns: flask.Response: The favicon.ico file """ return send_from_directory(app.static_folder, 'favicon.ico') @app.route('/get_predefined_locations') def get_predefined_locations_route(): """ API endpoint to get predefined locations. Returns: dict: Dictionary containing predefined location values """ values = it.get_predefined_locations() return jsonify({'locations': values}) @app.route('/add_location_value', methods=['POST']) def add_location_value(): """ Add a new predefined location value. Returns: flask.Response: Redirect to location management page """ if 'username' not in session or not us.check_admin(session['username']): return jsonify({'success': False, 'error': 'Not authorized'}), 403 value = sanitize_form_value(request.form.get('value')) if not value: flash('Bitte geben Sie einen Wert ein', 'error') return redirect(url_for('manage_locations')) # Add the value to locations success = it.add_predefined_location(value) if success: flash(f'Ort "{value}" wurde zur Liste hinzugefügt', 'success') else: flash(f'Ort "{value}" existiert bereits', 'error') return redirect(url_for('manage_locations')) @app.route('/remove_location_value/', methods=['POST']) def remove_location_value(value): """ Remove a predefined location value. Args: value (str): Value to remove Returns: flask.Response: Redirect to location management page """ if 'username' not in session or not us.check_admin(session['username']): return jsonify({'success': False, 'error': 'Not authorized'}), 403 # Remove the value from locations success = it.remove_predefined_location(value) if success: flash(f'Ort "{value}" wurde aus der Liste entfernt', 'success') else: flash(f'Fehler beim Entfernen des Ortes "{value}"', 'error') return redirect(url_for('manage_locations')) @app.route('/manage_locations') def manage_locations(): """ Admin page to manage predefined location values. Returns: flask.Response: Rendered location management template 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') return redirect(url_for('login')) if not us.check_admin(session['username']): flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error') return redirect(url_for('login')) # Get predefined location values location_values = it.get_predefined_locations() return render_template( 'manage_locations.html', location_values=location_values, library_module_enabled=cfg.LIBRARY_MODULE_ENABLED, student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED ) @app.route('/check_code_unique/') def check_code_unique(code): """ API endpoint to check if a code is unique Args: code (str): Code to check exclude_id (str, optional): ID of item to exclude from check (for edit operations) Returns: dict: JSON response with is_unique boolean """ exclude_id = request.args.get('exclude_id') is_unique = it.is_code_unique(code, exclude_id) return jsonify({ 'is_unique': is_unique, 'code': code }) @app.route('/schedule_appointment', methods=['POST']) def schedule_appointment(): """ Schedule an appointment for an item """ if 'username' not in session: return jsonify({'error': 'Nicht angemeldet'}), 401 try: # Extract form data item_id = request.form.get('item_id') specific_item_id = (request.form.get('specific_item_id') or '').strip() schedule_date = request.form.get('schedule_date') start_period = request.form.get('start_period') end_period = request.form.get('end_period') notes = request.form.get('notes', '') # Check for multi-day is_multi_day = request.form.get('is_multi_day') == 'on' schedule_end_date = request.form.get('schedule_end_date') # Validate inputs if not all([item_id, schedule_date, start_period, end_period]): return jsonify({'success': False, 'message': 'Pflichtfelder fehlen'}), 400 # Parse the start date try: appointment_date_obj = datetime.datetime.strptime(schedule_date, '%Y-%m-%d') appointment_date = appointment_date_obj.date() # Get date part only except ValueError: return jsonify({'success': False, 'message': 'Ungültiges Datumsformat'}), 400 # Parse end date if multi-day appointment_end_date = appointment_date if is_multi_day and schedule_end_date: try: appointment_end_date_obj = datetime.datetime.strptime(schedule_end_date, '%Y-%m-%d') appointment_end_date = appointment_end_date_obj.date() if appointment_end_date < appointment_date: return jsonify({'success': False, 'message': 'Enddatum kann nicht vor Startdatum liegen'}), 400 except ValueError: return jsonify({'success': False, 'message': 'Ungültiges Enddatumsformat'}), 400 # Validate periods try: start_period_num = int(start_period) end_period_num = int(end_period) # Only check period order if it's the same day if appointment_date == appointment_end_date and start_period_num > end_period_num: return jsonify({'success': False, 'message': 'Startperiode kann nicht nach Endperiode liegen'}), 400 if not (1 <= start_period_num <= 10) or not (1 <= end_period_num <= 10): return jsonify({'success': False, 'message': 'Ungültige Periodennummern'}), 400 except ValueError: return jsonify({'success': False, 'message': 'Ungültige Periodenwerte'}), 400 # Check if item exists item = it.get_item(item_id) if not item: return jsonify({'success': False, 'message': 'Element nicht gefunden'}), 404 # Grouped inventory mode: allow scheduling for a specific physical unit. try: client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] items_col = db['items'] grouped_children = list(items_col.find({'ParentItemId': item_id, 'IsGroupedSubItem': True})) client.close() except Exception: grouped_children = [] if grouped_children: grouped_units = [item] + [{**child, '_id': str(child.get('_id'))} for child in grouped_children] available_units = [unit for unit in grouped_units if unit.get('Verfuegbar', True)] chosen_unit = None if specific_item_id: chosen_unit = next((unit for unit in grouped_units if str(unit.get('_id')) == specific_item_id), None) if not chosen_unit: return jsonify({'success': False, 'message': 'Der gewählte Unterartikel wurde nicht gefunden.'}), 400 else: chosen_unit = available_units[0] if available_units else grouped_units[0] item_id = str(chosen_unit.get('_id')) item = chosen_unit # Check if item is reservable if not item.get('Reservierbar', True): return jsonify({'success': False, 'message': 'Dieses Item kann nicht reserviert werden.'}), 400 # Calculate start and end times for the appointment # Make sure we're passing the correct datetime object period_start_datetime = datetime.datetime.combine(appointment_date, datetime.time()) period_times_start = get_period_times(period_start_datetime, start_period_num) period_end_datetime = datetime.datetime.combine(appointment_end_date, datetime.time()) period_times_end = get_period_times(period_end_datetime, end_period_num) # Check if we got valid period times if not period_times_start or not period_times_end: print(f"Invalid period times: start={period_times_start}, end={period_times_end}") return jsonify({'success': False, 'message': 'Ungültige Periodenzeiten'}), 400 start_datetime = period_times_start['start'] end_datetime = period_times_end['end'] # Determine if we should use period-based booking or time-based (multi-day) # If it's multi-day (dates differ), we treat it as a continuous block (Period=None) # If it's single day, we use the period range logic booking_period = None booking_period_end = None if appointment_date == appointment_end_date: booking_period = start_period_num booking_period_end = end_period_num # Check for conflicts (use full period-range aware check) try: has_conflict = au.check_booking_period_range_conflict( item_id, start_datetime, end_datetime, period=booking_period, period_end=booking_period_end ) if has_conflict: return jsonify({'success': False, 'message': 'Termin kollidiert mit bestehender Buchung'}), 409 except Exception as e: print(f"Error checking for booking conflicts: {e}") return jsonify({'success': False, 'message': f'Fehler beim Prüfen der Verfügbarkeit: {str(e)}'}), 500 # Create the appointment as a planned booking try: appointment_id = au.add_planned_booking( item_id=item_id, user=session['username'], start_date=start_datetime, end_date=end_datetime, notes=notes, period=booking_period # Will be None for multi-day ) if not appointment_id: return jsonify({'success': False, 'message': 'Termin konnte nicht erstellt werden'}), 500 except Exception as e: print(f"Error creating planned booking: {e}") return jsonify({'success': False, 'message': f'Fehler beim Erstellen des Termins: {str(e)}'}), 500 # If we got this far, we have a valid appointment_id try: # Update item with next scheduled appointment info # Convert date to datetime for MongoDB storage if needed if isinstance(appointment_date, datetime.date) and not isinstance(appointment_date, datetime.datetime): appointment_datetime = datetime.datetime.combine(appointment_date, datetime.time()) else: appointment_datetime = appointment_date # Handle end date conversion if isinstance(appointment_end_date, datetime.date) and not isinstance(appointment_end_date, datetime.datetime): appointment_end_datetime = datetime.datetime.combine(appointment_end_date, datetime.time()) else: appointment_end_datetime = appointment_end_date result = it.update_item_next_appointment(item_id, { 'date': appointment_datetime, 'end_date': appointment_end_datetime, 'start_period': start_period_num, 'end_period': end_period_num, 'user': session['username'], 'notes': notes, 'appointment_id': str(appointment_id) }) if result: return jsonify({'success': True, 'appointment_id': str(appointment_id)}) else: print("Failed to update item with appointment info") return jsonify({'success': False, 'message': 'Element konnte nicht mit Termininformationen aktualisiert werden'}), 500 except Exception as e: print(f"Error updating item with appointment info: {e}") return jsonify({'success': False, 'message': f'Fehler beim Aktualisieren des Elements: {str(e)}'}), 500 except Exception as e: print(f"Error creating appointment: {e}") import traceback traceback.print_exc() return jsonify({'success': False, 'message': f'Serverfehler aufgetreten: {str(e)}'}), 500 @app.route('/cancel_ausleihung/', methods=['POST']) def cancel_ausleihung_route(id): """ Route for canceling a planned or active ausleihung. Args: id (str): ID of the ausleihung to cancel Returns: flask.Response: Redirect to My Ausleihungen page """ 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') return redirect(url_for('login')) username = session['username'] try: print(f"Attempting to cancel ausleihung with ID: {id}") # Get the ausleihung record to check if it belongs to the current user ausleihung = au.get_ausleihung(id) if not ausleihung: print(f"Ausleihung not found with ID: {id}") flash('Ausleihung nicht gefunden', 'error') return redirect(url_for('my_borrowed_items')) # Log ausleihung details for debugging ausleihung_status = ausleihung.get('Status', 'unknown') ausleihung_user = ausleihung.get('User', 'unknown') print(f"Found ausleihung: ID={id}, User={ausleihung_user}, Status={ausleihung_status}") # Check if the ausleihung belongs to the current user if ausleihung_user != username and not us.check_admin(username): print(f"Authorization failure: {username} attempted to cancel ausleihung belonging to {ausleihung_user}") flash('Sie sind nicht berechtigt, diese Ausleihung zu stornieren', 'error') return redirect(url_for('my_borrowed_items')) # Cancel the ausleihung if au.cancel_ausleihung(id): print(f"Successfully canceled ausleihung with ID: {id}") flash('Ausleihung wurde erfolgreich storniert', 'success') # Also clear NextAppointment on the related item if it matches this appointment try: item_id = str(ausleihung.get('Item')) if ausleihung.get('Item') is not None else None if item_id: item_doc = it.get_item(item_id) if item_doc: next_appt = item_doc.get('NextAppointment', {}) if next_appt and str(next_appt.get('appointment_id')) == str(id): cleared = it.clear_item_next_appointment(item_id) print(f"Cleared NextAppointment for item {item_id}: {cleared}") except Exception as clear_err: print(f"Warning: could not clear NextAppointment for cancelled ausleihung {id}: {clear_err}") else: print(f"Failed to cancel ausleihung with ID: {id}") flash('Fehler beim Stornieren der Ausleihung', 'error') except Exception as e: print(f"Error canceling ausleihung: {e}") flash(f'Fehler: {str(e)}', 'error') return redirect(url_for('my_borrowed_items')) @app.route('/reset_item/', methods=['POST']) def reset_item(id): """ Route for completely resetting an item's borrowing status. This handles items that have inconsistent borrowing states. Args: id (str): ID of the item to reset Returns: JSON: Success status and details """ if 'username' not in session: return jsonify({'success': False, 'error': 'Not authenticated'}), 401 if not us.check_admin(session['username']): return jsonify({'success': False, 'error': 'Not authorized'}), 403 try: # Import the ausleihung module import ausleihung as au result = au.reset_item_completely(id) if result['success']: return jsonify({ 'success': True, 'message': result['message'], 'details': result.get('details', {}) }) else: return jsonify({ 'success': False, 'message': result['message'] }), 400 except Exception as e: print(f"Error in reset_item route: {e}") import traceback traceback.print_exc() return jsonify({ 'success': False, 'error': f'Serverfehler: {str(e)}' }), 500 # New image and video optimization functions def is_image_file(filename): """ Check if a file is an image based on its extension. Args: filename (str): Name of the file to check Returns: bool: True if the file is an image, False otherwise """ image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.tif', '.svg'} extension = filename.lower()[filename.rfind('.'):] return extension in image_extensions def is_video_file(filename): """ Check if a file is a video based on its extension. Args: filename (str): Name of the file to check Returns: bool: True if the file is a video, False otherwise """ video_extensions = {'.mp4', '.mov', '.avi', '.mkv', '.webm', '.flv', '.m4v', '.3gp'} extension = filename.lower()[filename.rfind('.'):] return extension in video_extensions def normalize_image_orientation(img, log_prefix=""): """ Normalize image orientation using EXIF metadata. Many phone images are stored as "rotated + EXIF orientation tag". Converting them to other formats (e.g. WebP) without applying the EXIF transform can make portrait images appear sideways. """ try: return ImageOps.exif_transpose(img) except Exception as exif_err: if log_prefix: app.logger.warning(f"{log_prefix} Could not apply EXIF orientation: {str(exif_err)}") return img def create_image_thumbnail(image_path, thumbnail_path, size, debug_prefix=""): """ Create a thumbnail for an image file, always converting to WebP format. Args: image_path (str): Path to the original image thumbnail_path (str): Path where the thumbnail should be saved size (tuple): Thumbnail size as (width, height) debug_prefix (str, optional): Prefix for debug logs Returns: bool: True if thumbnail was created successfully, False otherwise """ # Check if this is a PNG file is_png = image_path.lower().endswith('.png') log_prefix = debug_prefix if debug_prefix else (f"PNG DEBUG: [{os.path.basename(image_path)}]" if is_png else "") try: if is_png and log_prefix: app.logger.info(f"{log_prefix} Creating thumbnail from PNG: {image_path} -> {thumbnail_path}") try: with Image.open(image_path) as img: img = normalize_image_orientation(img, log_prefix) if is_png and log_prefix: app.logger.info(f"{log_prefix} PNG opened successfully: Format={img.format}, Mode={img.mode}, Size={img.size}") # Create thumbnail with proper aspect ratio if is_png and log_prefix: app.logger.info(f"{log_prefix} Resizing PNG to {size}") try: img.thumbnail(size, Image.Resampling.LANCZOS) except Exception as resize_err: if is_png and log_prefix: app.logger.error(f"{log_prefix} Error during PNG resize: {str(resize_err)}") app.logger.info(f"{log_prefix} Trying alternative resize method") # Try alternative resize method img = img.resize((min(img.width, size[0]), min(img.height, size[1])), Image.Resampling.BILINEAR) # Create a new image with the exact size (add padding if needed) # Use RGBA for transparency support in WebP thumb = Image.new('RGBA', size, (255, 255, 255, 0)) # Calculate position to center the image x = (size[0] - img.size[0]) // 2 y = (size[1] - img.size[1]) // 2 # Convert image to RGBA if it's not already if img.mode != 'RGBA': img = img.convert('RGBA') thumb.paste(img, (x, y), img) # Ensure the thumbnail path ends with .webp if not thumbnail_path.lower().endswith('.webp'): thumbnail_path = os.path.splitext(thumbnail_path)[0] + '.webp' # Ensure target directory exists os.makedirs(os.path.dirname(thumbnail_path), exist_ok=True) # Save with optimization thumb.save(thumbnail_path, 'WEBP', quality=85, method=6) return True except Exception as img_err: # Special handling for corrupted PNGs if is_png and log_prefix: app.logger.error(f"{log_prefix} Error opening PNG with PIL: {str(img_err)}") # Try to fix the PNG if possible app.logger.info(f"{log_prefix} Attempting to fix corrupt PNG") try: # Create a placeholder thumbnail since we can't process this PNG thumb = Image.new('RGBA', size, (200, 200, 200, 255)) # Add text indicating error from PIL import ImageDraw draw = ImageDraw.Draw(thumb) text = "PNG Error" draw.text((size[0]//4, size[1]//2), text, fill=(0, 0, 0, 255)) # Continue with saving this placeholder app.logger.info(f"{log_prefix} Created placeholder for corrupt PNG") except Exception as fix_err: app.logger.error(f"{log_prefix} Failed to create PNG placeholder: {str(fix_err)}") raise img_err # Re-raise the original error if we couldn't create a placeholder else: # For non-PNG files, just propagate the error raise if not thumbnail_path.lower().endswith('.webp'): thumbnail_path = os.path.splitext(thumbnail_path)[0] + '.webp' os.makedirs(os.path.dirname(thumbnail_path), exist_ok=True) # Save with optimization thumb.save(thumbnail_path, 'WEBP', quality=85, method=6) return True except Exception as e: print(f"Error creating image thumbnail for {image_path}: {str(e)}") return False def create_video_thumbnail(video_path, thumbnail_path, size): """ Create a thumbnail for a video file using ffmpeg. Args: video_path (str): Path to the original video thumbnail_path (str): Path where the thumbnail should be saved size (tuple): Thumbnail size as (width, height) Returns: bool: True if thumbnail was created successfully, False otherwise """ try: # Use ffmpeg to extract a frame from the video (at 1 second) cmd = [ 'ffmpeg', '-i', video_path, '-ss', '00:00:01.000', # Extract frame at 1 second '-vframes', '1', '-y', # Overwrite output file thumbnail_path + '.temp.jpg' ] result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode == 0 and os.path.exists(thumbnail_path + '.temp.jpg'): # Now resize the extracted frame using PIL success = create_image_thumbnail(thumbnail_path + '.temp.jpg', thumbnail_path, size) # Clean up temporary file try: os.remove(thumbnail_path + '.temp.jpg') except: pass return success else: print(f"ffmpeg failed for {video_path}: {result.stderr}") return False except Exception as e: print(f"Error creating video thumbnail for {video_path}: {str(e)}") return False def generate_optimized_versions(filename, max_original_width=500, target_size_kb=80, debug_prefix=""): """ Generate optimized version of uploaded files. Convert all image files to WebP format. Also resizes and compresses the original image to save storage space. No separate thumbnail or preview files are generated. Args: filename (str): Name of the uploaded file max_original_width (int): Maximum width for the original image (default: 500px) target_size_kb (int): Target file size in kilobytes (default: 80KB) Returns: dict: Dictionary with paths to generated files """ # Create a process ID for logging process_id = str(uuid.uuid4())[:6] log_prefix = f"[Optimize-{process_id}][{filename}]" app.logger.info(f"{log_prefix} Starting optimization") # Make sure all required directories exist for directory in [app.config['UPLOAD_FOLDER']]: os.makedirs(directory, exist_ok=True) original_path = os.path.join(app.config['UPLOAD_FOLDER'], filename) # Fallback to production path if dev path missing if not os.path.exists(original_path): prod_upload = "/var/Inventarsystem/Web/uploads" alt_path = os.path.join(prod_upload, filename) if os.path.exists(alt_path): original_path = alt_path # Generate file paths name_part, ext = os.path.splitext(filename) ext = ext.lower() is_webp_ext = ext == '.webp' # If already a WebP, keep filename to avoid same-file writes converted_filename = filename if is_webp_ext else f"{name_part}.webp" converted_path = os.path.join(app.config['UPLOAD_FOLDER'], converted_filename) result = { 'original': converted_filename, # Use WebP name; if already WebP, this equals input 'thumbnail': None, 'preview': None, 'is_image': False, 'is_video': False, 'success': False } # Check if the file actually exists if not os.path.exists(original_path): app.logger.error(f"{log_prefix} Original file not found: {original_path}") # Check if we need to use a placeholder placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.svg') if not os.path.exists(placeholder_path): placeholder_path = os.path.join(app.static_folder, 'img', 'no-image.png') # Also check production static dir if not os.path.exists(placeholder_path): prod_static = "/var/Inventarsystem/Web/static/img" fallback_svg = os.path.join(prod_static, 'no-image.svg') fallback_png = os.path.join(prod_static, 'no-image.png') if os.path.exists(fallback_svg): placeholder_path = fallback_svg elif os.path.exists(fallback_png): placeholder_path = fallback_png if os.path.exists(placeholder_path): app.logger.info(f"{log_prefix} Using placeholder image instead") try: # Copy placeholder to uploads folder with the original filename shutil.copy2(placeholder_path, original_path) result['original'] = filename result['is_placeholder'] = True result['success'] = True return result except Exception as e: app.logger.error(f"{log_prefix} Failed to use placeholder: {str(e)}") return result else: app.logger.error(f"{log_prefix} No placeholder found, cannot continue") return result # Check if it's an image or video file is_png = filename.lower().endswith('.png') if is_image_file(filename): result['is_image'] = True app.logger.info(f"{log_prefix} Processing as image file") # Special logging for PNG files if is_png: if debug_prefix: app.logger.info(f"{debug_prefix} Processing PNG in optimization function") else: app.logger.info(f"PNG DEBUG: {log_prefix} Processing PNG in optimization function") elif is_video_file(filename): result['is_video'] = True app.logger.info(f"{log_prefix} Processing as video file") # For videos, we might want to extract a frame as the "image" representation # But for now, we'll just leave it as is, assuming the video itself is the asset # If a thumbnail is needed for video, we might need to generate one "poster" image # But the requirement says "only the downsized Image", which implies for images. # For videos, we'll just return success. result['success'] = True return result else: app.logger.info(f"{log_prefix} Not an image or video file, skipping optimization") return result try: # Get file info before processing original_size = os.path.getsize(original_path) app.logger.info(f"{log_prefix} Original size: {original_size/1024:.1f}KB") # Try to open and process the image try: with Image.open(original_path) as img: img = normalize_image_orientation(img, log_prefix) # Special handling for PNG is_png = filename.lower().endswith('.png') if is_png: debug_msg = debug_prefix if debug_prefix else f"PNG DEBUG: {log_prefix}" app.logger.info(f"{debug_msg} Processing PNG image in optimization function") app.logger.info(f"{debug_msg} PNG details - Format: {img.format}, Mode: {img.mode}") # Log original dimensions original_width, original_height = img.size app.logger.info(f"{log_prefix} Original dimensions: {original_width}x{original_height}") # Resize if needed resized = False if original_width > max_original_width: try: scaling_factor = max_original_width / original_width new_width = max_original_width new_height = int(original_height * scaling_factor) # Resize with high quality resampling img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) app.logger.info(f"{log_prefix} Resized to {new_width}x{new_height}") if is_png: app.logger.info(f"{debug_msg} PNG resized to {new_width}x{new_height}") resized = True except Exception as e: app.logger.error(f"{log_prefix} Resize failed: {str(e)}") if is_png: app.logger.error(f"{debug_msg} PNG resize failed: {str(e)}") app.logger.error(f"{debug_msg} Error type: {type(e).__name__}") # Continue without resizing # Save as WebP with compression to target file size try: # Get optimal quality setting to reach target size # For WebP we can use a fixed quality or adapt it quality = 80 # Default quality for WebP app.logger.info(f"{log_prefix} Using quality setting: {quality}") # Standard save for WebP if not is_webp_ext: # Only create a new WebP if source wasn't already WebP img.save(converted_path, 'WEBP', quality=quality, method=6) app.logger.info(f"{log_prefix} Saved optimized WebP: {converted_path}") # Remove the original non-WebP file after successful conversion if os.path.exists(converted_path): try: os.remove(original_path) app.logger.info(f"{log_prefix} Removed original file after conversion") except Exception as e: app.logger.warning(f"{log_prefix} Error removing original file: {str(e)}") else: # Already a WebP: don't overwrite original; we'll use it for thumbs app.logger.info(f"{log_prefix} Original is already WebP; skip in-place re-save") except Exception as save_err: app.logger.error(f"{log_prefix} Failed to save optimized WebP: {str(save_err)}") # Try with default quality as fallback (only when not already WebP) try: if not is_webp_ext: app.logger.info(f"{log_prefix} Attempting save with default quality") img.save(converted_path, 'WEBP', quality=80, method=6) app.logger.info(f"{log_prefix} Saved WebP with default quality") else: app.logger.info(f"{log_prefix} Skipping fallback save; original is WebP and won't be overwritten") except Exception as default_save_err: app.logger.error(f"{log_prefix} WebP fallback save also failed: {str(default_save_err)}") # If WebP conversion fails entirely and different path, copy original if not is_webp_ext and os.path.abspath(original_path) != os.path.abspath(converted_path): shutil.copy2(original_path, converted_path) app.logger.warning(f"{log_prefix} Used original file without optimization") # Compare file sizes if not is_webp_ext and os.path.exists(converted_path): new_size = os.path.getsize(converted_path) reduction = (1 - (new_size / original_size)) * 100 if original_size > 0 else 0 app.logger.info(f"{log_prefix} Size reduction: {original_size/1024:.1f}KB -> {new_size/1024:.1f}KB ({reduction:.1f}%)") # Remove the original non-WebP file if it was converted or resized if not is_webp_ext and os.path.exists(converted_path) and (not filename.lower().endswith('.webp') or resized): try: os.remove(original_path) app.logger.info(f"{log_prefix} Removed original file after conversion") except Exception as e: app.logger.warning(f"{log_prefix} Error removing original file: {str(e)}") except Exception as e: app.logger.error(f"{log_prefix} Compression error: {str(e)}") # Use original file if optimization fails if not os.path.exists(converted_path): shutil.copy2(original_path, converted_path) app.logger.warning(f"{log_prefix} Used original file as fallback") # Use the converted file for thumbnails if it exists # If we produced a converted WebP (non-WebP source), use it as the basis for thumbs if not is_webp_ext and os.path.exists(converted_path): original_path = converted_path except Exception as e: app.logger.error(f"{log_prefix} Failed to process image: {str(e)}") traceback.print_exc() # Just copy the original file as is if not is_webp_ext and os.path.exists(original_path) and not os.path.exists(converted_path): try: shutil.copy2(original_path, converted_path) app.logger.warning(f"{log_prefix} Used original file after processing error") except Exception as copy_err: app.logger.error(f"{log_prefix} Failed to copy original file: {str(copy_err)}") # Mark success if we have at least the original or converted file if os.path.exists(original_path) or os.path.exists(converted_path): result['success'] = True app.logger.info(f"{log_prefix} Optimization completed successfully") # Log verification of all created files for file_type, file_path in [ ('Original', original_path), ('Converted', converted_path) ]: if os.path.exists(file_path): file_size = os.path.getsize(file_path) / 1024.0 # KB app.logger.info(f"{log_prefix} {file_type}: {os.path.basename(file_path)} ({file_size:.1f}KB)") else: app.logger.warning(f"{log_prefix} {file_type} file missing: {os.path.basename(file_path)}") return result except Exception as e: app.logger.error(f"{log_prefix} Unhandled exception in optimization: {str(e)}") traceback.print_exc() # If anything went wrong but the original file exists, just use it if os.path.exists(original_path): try: # Copy original to all required outputs as last resort for target_path in [converted_path]: if not os.path.exists(os.path.dirname(target_path)): os.makedirs(os.path.dirname(target_path), exist_ok=True) shutil.copy2(original_path, target_path) result['original'] = filename result['success'] = True result['recovery'] = True app.logger.warning(f"{log_prefix} Recovery completed: using original file for all outputs") return result except Exception as recovery_err: app.logger.error(f"{log_prefix} Recovery failed: {str(recovery_err)}") return result def get_thumbnail_info(filename): """ Get thumbnail and preview information for a file. Returns the main image URL for all requests as we only use one image version now. Args: filename (str): Original filename Returns: dict: Dictionary with thumbnail and preview information """ if not filename: return {'has_thumbnail': False, 'has_preview': False} name_part, ext_part = os.path.splitext(filename) # Check if the file exists (either as WebP or original extension) # We prefer WebP if available webp_filename = f"{name_part}.webp" webp_path = os.path.join(app.config['UPLOAD_FOLDER'], webp_filename) original_path = os.path.join(app.config['UPLOAD_FOLDER'], filename) final_filename = filename has_image = False if os.path.exists(webp_path): final_filename = webp_filename has_image = True elif os.path.exists(original_path): final_filename = filename has_image = True else: # Check production paths prod_upload = "/var/Inventarsystem/Web/uploads" if os.path.exists(os.path.join(prod_upload, webp_filename)): final_filename = webp_filename has_image = True elif os.path.exists(os.path.join(prod_upload, filename)): final_filename = filename has_image = True # Build URLs based on the filename # We use the same URL for thumbnail and preview since we only have one image image_url = f"/uploads/{final_filename}" if has_image else None # Backward compatibility: Check for legacy thumbnails/previews thumbnail_url = image_url has_thumbnail = has_image # Check for legacy thumbnail files if we have a main image or not legacy_thumb_names = [ f"{name_part}_thumb.webp", f"{name_part}_thumb.jpg", f"{name_part}_thumb{ext_part}" ] for thumb_name in legacy_thumb_names: if os.path.exists(os.path.join(app.config['THUMBNAIL_FOLDER'], thumb_name)): thumbnail_url = f"/thumbnails/{thumb_name}" has_thumbnail = True break elif os.path.exists(os.path.join("/var/Inventarsystem/Web/thumbnails", thumb_name)): thumbnail_url = f"/thumbnails/{thumb_name}" has_thumbnail = True break # Check for legacy preview files preview_url = image_url has_preview = has_image legacy_preview_names = [ f"{name_part}_preview.webp", f"{name_part}_preview.jpg", f"{name_part}_preview{ext_part}" ] for preview_name in legacy_preview_names: if os.path.exists(os.path.join(app.config['PREVIEW_FOLDER'], preview_name)): preview_url = f"/previews/{preview_name}" has_preview = True break elif os.path.exists(os.path.join("/var/Inventarsystem/Web/previews", preview_name)): preview_url = f"/previews/{preview_name}" has_preview = True break return { 'has_thumbnail': has_thumbnail, 'has_preview': has_preview, 'thumbnail_url': thumbnail_url, 'preview_url': preview_url, 'original_ext': os.path.splitext(final_filename)[1].lower(), 'is_image': is_image_file(final_filename), 'is_video': is_video_file(final_filename) } # Mobile device detection utilities def is_mobile_device(request): """Determine if the request is coming from a mobile device""" user_agent = request.headers.get('User-Agent', '').lower() mobile_identifiers = ['iphone', 'ipad', 'android', 'mobile', 'tablet'] return any(identifier in user_agent for identifier in mobile_identifiers) def is_ios_device(request): """Determine if the request is coming from an iOS device""" user_agent = request.headers.get('User-Agent', '').lower() return 'iphone' in user_agent or 'ipad' in user_agent or 'ipod' in user_agent def log_mobile_action(action, request, success=True, details=None): """Log mobile-specific actions for debugging""" device_info = request.headers.get('User-Agent', 'Unknown device') status = "SUCCESS" if success else "FAILED" message = f"MOBILE {action} {status} - Device: {device_info}" if details: message += f" - Details: {details}" if success: app.logger.info(message) else: app.logger.error(message) # Add explicit static file routes to handle CSS serving issues @app.route('/static/') def serve_static(filename): """ Explicitly serve static files to resolve 403 Forbidden errors. This ensures CSS and JS files are properly accessible. Args: filename (str): The static file path Returns: flask.Response: The requested static file """ return send_from_directory(app.static_folder, filename) @app.route('/static/css/') def serve_css(filename): """ Explicitly serve CSS files from the static/css directory. Args: filename (str): Name of the CSS file to serve Returns: flask.Response: The requested CSS file """ css_folder = os.path.join(app.static_folder, 'css') return send_from_directory(css_folder, filename) @app.route('/static/js/') def serve_js(filename): """ Explicitly serve JavaScript files from the static/js directory. Args: filename (str): Name of the JS file to serve Returns: flask.Response: The requested JS file """ js_folder = os.path.join(app.static_folder, 'js') return send_from_directory(js_folder, filename) @app.route('/log_mobile_issue', methods=['POST']) def log_mobile_issue(): """ Route for logging mobile-specific issues. Used for tracking and debugging mobile browser problems. Returns: flask.Response: JSON response with success status """ try: # Get issue data from request issue_data = request.json # Add timestamp if not present if 'timestamp' not in issue_data: issue_data['timestamp'] = datetime.now().isoformat() # Format the log message log_message = f"MOBILE ISSUE: {issue_data.get('action', 'unknown')} - " log_message += f"Error: {issue_data.get('error', 'none')} - " log_message += f"Browser: {issue_data.get('browser', 'unknown')}" # Create a structured log entry log_entry = { 'type': 'mobile_issue', 'timestamp': issue_data.get('timestamp'), 'data': issue_data } # Log to application log file app.logger.warning(log_message) # Store in database for analytics client = MongoClient(MONGODB_HOST, MONGODB_PORT) db = client[MONGODB_DB] logs_collection = db['system_logs'] logs_collection.insert_one(log_entry) client.close() return jsonify({'success': True}) except Exception as e: app.logger.error(f"Error logging mobile issue: {str(e)}") return jsonify({'success': False, 'error': str(e)}) def delete_item_images(filenames): """ Delete all images associated with an item. Only deletes the main image file as we no longer use separate thumbnails/previews. Args: filenames (list): List of image filenames to delete Returns: dict: Statistics of deleted files """ stats = { 'originals': 0, 'thumbnails': 0, 'previews': 0, 'errors': 0 } if not filenames: return stats for filename in filenames: if not filename: continue try: # Generate paths based on filename pattern name_part = os.path.splitext(filename)[0] # Potential original files (WebP or JPG or original extension) files_to_check = [ (os.path.join(app.config['UPLOAD_FOLDER'], f"{name_part}.webp"), 'originals'), (os.path.join(app.config['UPLOAD_FOLDER'], f"{name_part}.jpg"), 'originals'), (os.path.join(app.config['UPLOAD_FOLDER'], filename), 'originals'), # Also try to clean up legacy thumbnails/previews if they exist (os.path.join(app.config['THUMBNAIL_FOLDER'], f"{name_part}_thumb.webp"), 'thumbnails'), (os.path.join(app.config['THUMBNAIL_FOLDER'], f"{name_part}_thumb.jpg"), 'thumbnails'), (os.path.join(app.config['PREVIEW_FOLDER'], f"{name_part}_preview.webp"), 'previews'), (os.path.join(app.config['PREVIEW_FOLDER'], f"{name_part}_preview.jpg"), 'previews') ] # Delete all found files for file_path, category in files_to_check: if os.path.exists(file_path): try: os.remove(file_path) stats[category] += 1 except Exception as del_err: app.logger.error(f"Failed to delete {file_path}: {del_err}") stats['errors'] += 1 except Exception as e: app.logger.error(f"Error deleting image files for {filename}: {str(e)}") stats['errors'] += 1 return stats def get_optimal_image_quality(img, target_size_kb=80): """ Find the optimal JPEG quality setting to achieve a target file size. Uses a binary search approach to efficiently find the best quality. Args: img (PIL.Image): The PIL Image object target_size_kb (int): Target file size in kilobytes Returns: int: Quality setting (1-95) """ import io # Initialize search range min_quality = 30 # We don't want to go lower than this max_quality = 95 # No need to go higher than this best_quality = 80 # Default quality best_diff = float('inf') target_size_bytes = target_size_kb * 1024 # Binary search for optimal quality for _ in range(5): # 5 iterations is usually enough quality = (min_quality + max_quality) // 2 buffer = io.BytesIO() img.save(buffer, format='JPEG', quality=quality, optimize=True) size = buffer.tell() # Check how close we are to target size diff = abs(size - target_size_bytes) if diff < best_diff: best_diff = diff best_quality = quality # Adjust search range if size > target_size_bytes: max_quality = quality - 1 else: min_quality = quality + 1 # If we’re within 10% of target, that’s good enough if abs(size - target_size_bytes) < (target_size_bytes * 0.1): return quality return best_quality