Files
Inventarsystem/Web/app.py
T
2026-07-16 08:15:15 +02:00

12086 lines
493 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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, abort
from werkzeug.utils import secure_filename
from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.exceptions import HTTPException
from werkzeug.routing import BuildError
from jinja2 import TemplateNotFound
import os
import sys
from bs4 import BeautifulSoup
# Ensure imports work regardless of whether gunicorn starts in /app or /app/Web.
_CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
_PROJECT_ROOT = os.path.dirname(_CURRENT_DIR)
if _PROJECT_ROOT not in sys.path:
sys.path.insert(0, _PROJECT_ROOT)
if _CURRENT_DIR not in sys.path:
sys.path.insert(0, _CURRENT_DIR)
import Web.modules.database.user as us
import Web.modules.database.items as it
import Web.modules.database.ausleihung as au
import Web.modules.database.termine as termin
import Web.modules.log.audit_log as al
import push_notifications as pn
import Web.modules.inventarsystem.pdf_export as pdf_export
import Web.modules.inventarsystem.excel_export as excel_export
import datetime
from apscheduler.schedulers.background import BackgroundScheduler
from bson.objectid import ObjectId
from urllib.parse import urlparse, urlunparse
import requests
import csv
import ipaddress
import json
import datetime
import time
import traceback
import re
import socket
import io
import html
import logging
from logging.handlers import RotatingFileHandler
import secrets
import importlib
try:
redis = importlib.import_module('redis')
except Exception:
redis = None
# QR Code functionality deactivated
# import qrcode
# from qrcode.constants import ERROR_CORRECT_L
import threading
import shutil
import uuid
from PIL import Image, ImageOps
import mimetypes
import subprocess
from Web.modules.inventarsystem.data_protection import (
decrypt_document_fields,
decrypt_text,
encrypt_document_fields,
encrypt_soft_deleted_media_pack,
encrypt_text,
)
from Web.modules.terminplaner.blueprint import appoint_bp as terminplaner_bp
# Set base directory and centralized settings
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient
from tenant import get_tenant_context, get_tenant_db, get_tenant_trial_status, purge_expired_trial_tenants
app = Flask(__name__, static_folder='static') # Correctly set static folder
app.logger.setLevel(logging.DEBUG)
if not os.path.exists(cfg.LOGS_FOLDER):
os.makedirs(cfg.LOGS_FOLDER, exist_ok=True)
log_file_path = os.path.join(cfg.LOGS_FOLDER, 'application.log')
file_handler = RotatingFileHandler(log_file_path, maxBytes=10 * 1024 * 1024, backupCount=5, encoding='utf-8')
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(name)s %(message)s'))
app.logger.handlers = []
app.logger.addHandler(file_handler)
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['MAX_CONTENT_LENGTH'] = max(cfg.MAX_UPLOAD_MB, cfg.IMAGE_MAX_UPLOAD_MB, cfg.VIDEO_MAX_UPLOAD_MB) * 1024 * 1024
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
app.config['SESSION_COOKIE_SECURE'] = cfg.SSL_ENABLED if os.getenv('INVENTAR_SESSION_COOKIE_SECURE') is None else os.getenv('INVENTAR_SESSION_COOKIE_SECURE', '').strip().lower() in ('1', 'true', 'yes', 'on')
app.config['PREFERRED_URL_SCHEME'] = 'https' if app.config['SESSION_COOKIE_SECURE'] else 'http'
# app.config['QR_CODE_FOLDER'] = cfg.QR_CODE_FOLDER # QR Code storage deactivated
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
app.register_blueprint(terminplaner_bp, url_prefix='/terminplaner')
"""--------------------------------------------------------------Path Init-------------------------------------------------------"""
if not os.path.exists(app.config['UPLOAD_FOLDER']):
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
# QR Code directory creation deactivated
# if not os.path.exists(app.config['QR_CODE_FOLDER']):
# os.makedirs(app.config['QR_CODE_FOLDER'])
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 print(*args, **kwargs):
if not args:
return None
message = " ".join(str(arg) for arg in args)
stripped = message.lstrip()
if stripped.startswith(('Error', 'Fehler', 'Warning', 'Warnung', '[WARN]', '[KONFLIKT]', 'Failed', 'Fehl')):
app.logger.warning(message)
elif stripped.startswith(('Exception', 'Traceback')):
app.logger.error(message)
else:
app.logger.info(message)
STUDENT_CARD_ENCRYPTED_FIELDS = ('SchülerName', 'Klasse', 'Notizen')
def _decrypt_student_card_doc(card_doc):
if not card_doc:
return card_doc
return decrypt_document_fields(card_doc, STUDENT_CARD_ENCRYPTED_FIELDS)
def _parse_and_increment_class(klass, max_class=13, graduate_label=''):
"""Parse a class string like '7A' or '10' and increment the numeric part.
If the numeric part would exceed max_class, return graduate_label (or empty).
Leaves non-numeric / unparsable values unchanged.
"""
if not klass:
return klass
s = str(klass).strip()
m = re.match(r"^\s*(\d{1,2})(\D.*)?$", s)
if not m:
return s
try:
num = int(m.group(1))
except Exception:
return s
suffix = m.group(2) or ''
new_num = num + 1
if max_class is not None and new_num > int(max_class):
return graduate_label or ''
return f"{new_num}{suffix}"
def rollover_student_card_classes(dry_run=False, *, max_class=None, graduate_label=''):
"""Increment class years on all student cards.
Returns a summary dict with counts.
"""
client = None
updated = 0
examined = 0
failures = 0
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
col = db['student_cards']
now = datetime.datetime.now()
cursor = list(col.find({}, {'Klasse': 1}))
for doc in cursor:
examined += 1
try:
# Decrypt to work on plain class string
plain = dict(doc)
plain = _decrypt_student_card_doc(plain)
orig = (plain.get('Klasse') or '').strip()
new_class = _parse_and_increment_class(orig, max_class=max_class, graduate_label=graduate_label)
if new_class != orig:
if dry_run:
updated += 1
continue
payload = {'Klasse': new_class}
encrypted = encrypt_document_fields(payload, STUDENT_CARD_ENCRYPTED_FIELDS)
update_doc = {'Klasse': encrypted.get('Klasse'), 'Aktualisiert': now}
col.update_one({'_id': doc.get('_id')}, {'$set': update_doc})
updated += 1
except Exception:
failures += 1
app.logger.exception('Failed to process student card rollover for %s', doc.get('_id'))
finally:
if client:
client.close()
summary = {'examined': examined, 'updated': updated, 'failures': failures, 'dry_run': bool(dry_run)}
try:
_append_audit_event_standalone('student_cards_rollover', summary)
except Exception:
app.logger.warning('Audit write failed for student_cards_rollover')
return summary
# Admin route to trigger rollover manually
@app.route('/admin/trigger_school_year_rollover', methods=['POST'])
def admin_trigger_school_year_rollover():
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
max_class = int(os.getenv('INVENTAR_SCHOOL_MAX_CLASS', '13'))
graduate_label = os.getenv('INVENTAR_SCHOOL_GRADUATE_LABEL', '')
dry = request.args.get('dry', '0') in ('1', 'true', 'yes')
summary = rollover_student_card_classes(dry_run=dry, max_class=max_class, graduate_label=graduate_label)
return jsonify({'ok': True, 'summary': summary}), 200
# Schedule annual rollover job using APScheduler (configurable via env)
try:
if cfg.SCHEDULER_ENABLED:
_rollover_month = getattr(cfg, 'SCHOOL_ROLLOVER_MONTH', 9)
_rollover_day = getattr(cfg, 'SCHOOL_ROLLOVER_DAY', 1)
_rollover_hour = getattr(cfg, 'SCHOOL_ROLLOVER_HOUR', 3)
_rollover_minute = getattr(cfg, 'SCHOOL_ROLLOVER_MIN', 0)
_rollover_max_class = getattr(cfg, 'SCHOOL_ROLLOVER_MAX_CLASS', 13)
_rollover_grad_label = getattr(cfg, 'SCHOOL_ROLLOVER_GRADUATE_LABEL', '')
_scheduler = BackgroundScheduler()
# Use a cron-style yearly job on the configured month/day
_scheduler.add_job(
func=lambda: rollover_student_card_classes(dry_run=False, max_class=_rollover_max_class, graduate_label=_rollover_grad_label),
trigger='cron',
month=_rollover_month,
day=_rollover_day,
hour=_rollover_hour,
minute=_rollover_minute,
id='school_year_rollover',
replace_existing=True
)
_scheduler.start()
app.logger.info('Scheduled annual school year rollover: %s-%s %s:%s', _rollover_month, _rollover_day, _rollover_hour, _rollover_minute)
except Exception as e:
app.logger.warning('Failed to schedule school year rollover: %s', e)
# 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', 'other', 'schoolbook', 'Buch', 'Schulbuch', 'schulbuch')
INVOICE_CURRENCY = 'EUR'
NOTIFICATION_STATUS_CACHE_TTL = max(3, int(os.getenv('INVENTAR_NOTIFICATION_STATUS_CACHE_TTL', '8')))
_NOTIFICATION_CACHE_PREFIX = 'inventar:notif'
_NOTIFICATION_REDIS_CLIENT = None
_NOTIFICATION_REDIS_FAILED = False
_NOTIFICATION_LOCAL_CACHE = {}
_NOTIFICATION_LOCAL_VERSIONS = {'admin': 0}
_NOTIFICATION_CACHE_LOCK = threading.Lock()
SCHOOL_PERIODS = cfg.SCHOOL_PERIODS
PERMISSION_ACTION_OPTIONS = [
('can_borrow', 'Ausleihe erlauben'),
('can_insert', 'Einfügen/Hochladen erlauben'),
('can_edit', 'Bearbeiten erlauben'),
('can_delete', 'Löschen erlauben'),
('can_manage_users', 'Benutzerverwaltung erlauben'),
('can_manage_settings', 'Systemverwaltung erlauben'),
('can_view_logs', 'Logs/Audit einsehen erlauben'),
]
PERMISSION_PAGE_OPTIONS = [
('home', 'Artikel (Inventar)'),
('tutorial_page', 'Tutorial'),
('my_borrowed_items', 'Meine Ausleihen'),
('notifications_view', 'Benachrichtigungen'),
('impressum', 'Impressum'),
('license', 'Lizenz'),
('library_view', 'Bibliothek (Medien)'),
('terminplan', 'Terminplan'),
('home_admin', 'Admin Startseite'),
('upload_admin', 'Admin Upload Inventar'),
('library_admin', 'Admin Upload Bibliothek'),
('admin_borrowings', 'Admin Ausleihen'),
('library_loans_admin', 'Admin Bibliotheks-Ausleihen'),
('admin_damaged_items', 'Admin Defekte Items'),
('admin_audit_dashboard', 'Admin Audit Dashboard'),
('logs', 'System-Logs'),
('user_del', 'Benutzerverwaltung'),
('register', 'Benutzer anlegen'),
('manage_filters', 'Filter verwalten'),
('manage_locations', 'Orte verwalten'),
]
PERMISSION_EXEMPT_ENDPOINTS = {
'static',
'login',
'logout',
'impressum',
'license',
'uploaded_file',
'thumbnail_file',
'preview_file',
}
PERMISSION_ACTION_ENDPOINTS = {
'upload_item': 'can_insert',
'upload_inventory_excel': 'can_insert',
'upload_library_excel': 'can_insert',
'upload_student_cards_excel': 'can_insert',
'edit_item': 'can_edit',
'api_library_item_update': 'can_edit',
'admin_update_user_name': 'can_edit',
'delete_item': 'can_delete',
'delete_user': 'can_delete',
'ausleihen': 'can_borrow',
'zurueckgeben': 'can_borrow',
'api_library_scan_action': 'can_borrow',
'user_del': 'can_manage_users',
'register': 'can_manage_users',
'admin_reset_user_password': 'can_manage_users',
'admin_update_user_permissions': 'can_manage_users',
'admin_anonymize_names': 'can_manage_users',
'home_admin': 'can_manage_settings',
'upload_admin': 'can_insert',
'library_admin': 'can_insert',
'admin_borrowings': 'can_manage_settings',
'library_loans_admin': 'can_manage_settings',
'admin_damaged_items': 'can_manage_settings',
'manage_filters': 'can_manage_settings',
'manage_locations': 'can_manage_settings',
'admin_audit_dashboard': 'can_view_logs',
'logs': 'can_view_logs',
}
ALLOWED_COVER_DOMAINS = {
"books.google.com",
"covers.openlibrary.org",
"images-na.ssl-images-amazon.com",
"m.media-amazon.com",
"www.isbn.de",
"covers.openlibrary.org",
"openlibrary.org",
"lobid.org",
"www.googleapis.com"
}
SENSITIVE_AUDIT_FIELDS = ["email", "username", "full_name", "phone", "borrower", "ip"]
# Apply the configuration for general use throughout the app
APP_VERSION = __version__
RELEASE_STATE_FILE = os.path.join(os.path.dirname(BASE_DIR), '.release-version')
""" -----------------------------------------------------------------------Before Request Handlers---------------------------------------------------------------------------- """
def _get_csrf_token():
token = session.get('_csrf_token')
if not token:
token = secrets.token_urlsafe(32)
session['_csrf_token'] = token
return token
def _is_csrf_exempt_request():
return request.method in {'GET', 'HEAD', 'OPTIONS', 'TRACE'}
@app.before_request
def _enforce_csrf_protection():
if _is_csrf_exempt_request():
_get_csrf_token()
return None
expected_token = _get_csrf_token()
provided_token = (
request.headers.get('X-CSRFToken')
or request.headers.get('X-CSRF-Token')
or request.form.get('csrf_token')
or request.args.get('csrf_token')
)
if not provided_token or not secrets.compare_digest(provided_token, expected_token):
return _csrf_error_response()
return None
@app.before_request
def _enforce_user_permissions():
endpoint = request.endpoint
if not endpoint:
return None
if endpoint == 'static' or endpoint.startswith('static'):
return None
if endpoint in PERMISSION_EXEMPT_ENDPOINTS:
return None
if 'username' not in session:
return None
permissions = _get_current_user_permissions()
if not permissions:
return None
if not _page_access_allowed(permissions, endpoint):
message = 'Diese Seite ist für Ihren Benutzer aktuell gesperrt.'
if request.path.startswith('/api/') or request.is_json:
return jsonify({'ok': False, 'message': message}), 403
flash(message, 'error')
fallback_endpoint = _permission_denied_fallback_endpoint(permissions, current_endpoint=endpoint)
return redirect(url_for(fallback_endpoint))
action_key = PERMISSION_ACTION_ENDPOINTS.get(endpoint)
if action_key and not _action_access_allowed(permissions, action_key):
message = 'Für diese Aktion fehlen Ihnen die erforderlichen Berechtigungen.'
if request.path.startswith('/api/') or request.is_json:
return jsonify({'ok': False, 'message': message}), 403
flash(message, 'error')
fallback_endpoint = _permission_denied_fallback_endpoint(permissions, current_endpoint=endpoint)
return redirect(url_for(fallback_endpoint))
return None
@app.before_request
def _enforce_active_session_user():
endpoint = request.endpoint or ''
if endpoint == 'static' or endpoint.startswith('static'):
return None
username = session.get('username')
if not username:
return None
user = us.get_user(username)
if user:
return None
session.clear()
if request.path.startswith('/api/') or request.is_json:
return jsonify({'ok': False, 'message': 'Sitzung ungültig. Bitte erneut anmelden.'}), 401
flash('Ihre Sitzung ist nicht mehr gültig. Bitte erneut anmelden.', 'error')
return redirect(url_for('login'))
@app.before_request
def _enforce_trial_tenant_expiry():
endpoint = request.endpoint or ''
if endpoint == 'static' or endpoint.startswith('static'):
return None
if endpoint in {'login', 'logout', 'impressum', 'license'}:
return None
try:
ctx = get_tenant_context()
tenant_id = ctx.tenant_id if ctx else session.get('tenant_id')
if not tenant_id:
return None
trial_status = get_tenant_trial_status(tenant_id)
if not trial_status.get('enabled'):
return None
session['trial_status'] = {
'enabled': True,
'expired': bool(trial_status.get('expired')),
'days_left': trial_status.get('days_left'),
'expires_at': trial_status.get('expires_at').isoformat() if trial_status.get('expires_at') else None,
}
if not trial_status.get('expired'):
return None
if request.path.startswith('/api/') or request.is_json:
return jsonify({
'ok': False,
'message': 'Diese Testinstanz ist abgelaufen und wurde deaktiviert.',
'expired': True,
}), 410
session.pop('username', None)
session.pop('admin', None)
session.pop('is_admin', None)
session.pop('favorites', None)
session.pop('favorites_owner', None)
flash('Diese Testinstanz ist abgelaufen und wurde deaktiviert.', 'error')
return redirect(url_for('login'))
except Exception as exc:
app.logger.warning(f'Trial expiry check failed: {exc}')
return None
@app.before_request
def _enforce_module_access():
endpoint = request.endpoint or ''
if endpoint == 'static' or endpoint.startswith('static'):
return None
for name, matcher in cfg.MODULES._path_matchers.items():
if matcher(request.path) and not cfg.MODULES.is_enabled(name):
msg = {
'library': "Bibliotheks-Modul ist deaktiviert.",
'inventory': "Inventar-Modul ist deaktiviert.",
'student_cards': "Schülerausweis-Modul ist deaktiviert."
}.get(name, f"{name.capitalize()}-Modul ist deaktiviert.")
if request.path.startswith('/api/') or request.is_json:
return jsonify({'ok': False, 'message': msg}), 403
flash(msg, 'info')
if name != 'inventory' and cfg.MODULES.is_enabled('inventory'):
return redirect(url_for('home'))
elif name != 'library' and cfg.MODULES.is_enabled('library'):
return redirect('/library')
return redirect(url_for('my_borrowed_items'))
""" -----------------------------------------------------------After Request Handlers----------------------------------------------------------------------------- """
@app.after_request
def _set_security_headers(response):
response.headers.setdefault('X-Content-Type-Options', 'nosniff')
response.headers.setdefault('X-Frame-Options', 'SAMEORIGIN')
response.headers.setdefault('Referrer-Policy', 'strict-origin-when-cross-origin')
if cfg.SSL_ENABLED:
response.headers.setdefault('Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
# Optimize caching for static resources (images, etc.)
path = request.path
# Aggressive caching for optimized images (480p) - they're immutable
if '/image/optimized/' in path:
response.headers['Cache-Control'] = 'public, max-age=2592000, immutable' # 30 days
# Moderate caching for thumbnails
elif '/thumbnails/' in path:
response.headers['Cache-Control'] = 'public, max-age=604800' # 7 days
# Moderate caching for previews
elif '/previews/' in path:
response.headers['Cache-Control'] = 'public, max-age=604800' # 7 days
# Short cache for regular uploads (in case they're updated/deleted)
elif '/uploads/' in path:
response.headers['Cache-Control'] = 'public, max-age=3600' # 1 hour
# Ensure WebP images are served with correct content-type
if path.endswith('.webp') or '.webp' in path:
response.headers['Content-Type'] = 'image/webp'
return response
@app.errorhandler(TemplateNotFound)
def handle_template_not_found(e):
"""Handle missing template files with a 404 response."""
app.logger.error(f"Template not found: {e.name}")
if request.is_json or request.path.startswith('/api/'):
return jsonify({'error': 'Template not found', 'status': 404}), 404
return abort(404)
@app.errorhandler(404)
def handle_not_found(e):
"""Handle 404 errors with a user-friendly response."""
app.logger.warning(f"404 Not Found: {request.path}")
if request.is_json or request.path.startswith('/api/'):
return jsonify({'error': 'Not found', 'status': 404}), 404
if 'username' in session:
return render_template('error.html', error_code=404, error_message='Seite nicht gefunden.'), 404
return redirect(url_for('login'))
@app.errorhandler(BuildError)
def handle_build_error(e):
"""Handle url_for endpoint mismatches without crashing the whole request."""
app.logger.error(f"BuildError while resolving endpoint: {e}", exc_info=True)
if request.is_json or request.path.startswith('/api/'):
return jsonify({'error': 'Route resolution failed', 'status': 500}), 500
# Known fallback for legacy library endpoint naming issues.
if 'library_view' in str(e):
return redirect('/library')
if 'username' in session:
return redirect(url_for('home'))
return redirect(url_for('login'))
@app.errorhandler(Exception)
def handle_unexpected_exception(e):
"""Last-resort handler to avoid raw 500 pages for unhandled exceptions."""
if isinstance(e, HTTPException):
return e
app.logger.error('Unhandled exception', exc_info=True)
if request.is_json or request.path.startswith('/api/'):
return jsonify({'error': 'Internal server error', 'status': 500}), 500
try:
return render_template(
'maintenance.html',
error_code=500,
error_message='Das System führt gerade Wartungsarbeiten durch.',
), 500
except Exception:
try:
return render_template('error.html', error_code=500, error_message='Das System führt gerade Wartungsarbeiten durch.'), 500
except Exception:
return 'Internal Server Error', 500
def _csrf_error_response(message='CSRF token fehlt oder ist ungültig.'):
if request.is_json or request.path.startswith('/api/') or request.path in {'/download_book_cover', '/proxy_image', '/log_mobile_issue'}:
return jsonify({'error': message}), 400
flash(message, 'error')
return redirect(url_for('login'))
def _get_current_module(path):
"""Resolve the active UI module for navbar separation."""
mod = cfg.MODULES.get_module_for_path(path)
if mod:
session['last_module'] = mod
return mod
last_module = session.get('last_module')
if last_module and cfg.MODULES.is_enabled(last_module):
return last_module
if cfg.MODULES.is_enabled('inventory'):
return 'inventory'
return 'library' if cfg.MODULES.is_enabled('library') else 'inventory'
"""---------------------------------------------User Access Permissions----------------------------------------------------------------------------- """
def _get_current_user_permissions():
username = session.get('username')
if not username:
return None
try:
return us.get_effective_permissions(username)
except Exception:
return us.build_default_permission_payload('standard_user')
def _page_access_allowed(permissions, endpoint):
if not permissions or not endpoint:
return True
page_permissions = permissions.get('pages', {})
return bool(page_permissions.get(endpoint, True))
def _action_access_allowed(permissions, action_key):
if not permissions or not action_key:
return True
action_permissions = permissions.get('actions', {})
return bool(action_permissions.get(action_key, True))
def _permission_denied_fallback_endpoint(permissions, current_endpoint=None):
username = session.get('username')
is_admin_user = bool(username and us.check_admin(username))
admin_home_allowed = _page_access_allowed(permissions, 'home_admin') and _action_access_allowed(permissions, 'can_manage_settings')
for candidate in ('my_borrowed_items', 'tutorial_page', 'notifications_view', 'impressum', 'home'):
if current_endpoint and candidate == current_endpoint:
continue
if candidate == 'home' and is_admin_user and not admin_home_allowed:
continue
if _page_access_allowed(permissions, candidate):
return candidate
return 'logout'
"""-------------------------------------------------------------Audit Logging----------------------------------------------------------------------------- """
def _append_audit_event_standalone(event_type, payload):
"""Write audit event by opening a short-lived DB connection."""
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
try:
al.append_audit_event(
db=db,
event_type=event_type,
actor=session.get('username', 'system'),
payload=str(payload),
request_ip=request.remote_addr,
source='web',
)
except Exception as exc:
app.logger.warning(f"Audit write failed for {event_type}: {exc}")
except Exception as exc:
app.logger.warning(f"Standalone audit write failed for {event_type}: {exc}")
finally:
if client:
client.close()
AUDIT_INDEXES_READY = False
def _ensure_audit_indexes_once():
"""Ensure audit indexes exist once per process."""
global AUDIT_INDEXES_READY
if AUDIT_INDEXES_READY:
return
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
al.ensure_audit_indexes(db)
AUDIT_INDEXES_READY = True
except Exception as exc:
app.logger.warning(f"Could not ensure audit indexes: {exc}")
finally:
if client:
client.close()
"""-------------------------------------------------------------School Info & Logo Handling----------------------------------------------------------------------------- """
def _get_school_info_for_export():
"""
Get school information for PDF exports from configuration or database.
Returns default info if not configured.
"""
try:
if hasattr(cfg, 'get_school_info'):
return cfg.get_school_info()
school_info = {
'name': 'Schulname',
'address': 'Schuladresse',
'postal_code': 'PLZ',
'city': 'Stadt',
'school_number': '000000',
'it_admin': 'IT-Beauftragter/in',
'logo_path': '',
}
return school_info
except Exception:
# Return defaults if anything fails
return {
'name': 'Schulname',
'address': 'Schuladresse',
'postal_code': 'PLZ',
'city': 'Stadt',
'school_number': '000000',
'it_admin': 'IT-Beauftragter/in',
'logo_path': '',
}
def _save_school_logo_upload(upload_file, tenant_id=None, tenant_db=None):
"""Save an uploaded school logo to the shared upload folder with a tenant-specific filename."""
if not upload_file or not getattr(upload_file, 'filename', ''):
return None
is_allowed, error_message = allowed_file(upload_file.filename, upload_file, max_size_mb=cfg.IMAGE_MAX_UPLOAD_MB)
if not is_allowed:
raise ValueError(error_message)
safe_tenant = re.sub(r'[^a-zA-Z0-9_\-]+', '_', str(tenant_id or tenant_db or 'default').strip())
safe_tenant = safe_tenant.strip('_') or 'default'
_, original_ext = os.path.splitext(secure_filename(upload_file.filename))
extension = (original_ext or '').lower()
if not extension:
raise ValueError('Das Logo benötigt eine Dateiendung.')
logo_filename = f'school-logo-{safe_tenant}{extension}'
logo_path = os.path.join(app.config['UPLOAD_FOLDER'], logo_filename)
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
upload_file.save(logo_path)
# Generate a thumbnail for consistent navbar sizing
try:
thumb_filename = f'school-logo-{safe_tenant}-thumb.png'
thumb_path = os.path.join(app.config['UPLOAD_FOLDER'], thumb_filename)
with Image.open(logo_path) as img:
# Ensure RGBA for transparency and convert if needed
if img.mode not in ('RGBA', 'RGB'):
img = img.convert('RGBA')
# Target max size: width up to 520px, height up to 114px (matches CSS constraints)
max_thumb_size = (520, 114)
img.thumbnail(max_thumb_size, Image.LANCZOS)
# Save as PNG for predictable rendering in web UI
img.save(thumb_path, format='PNG', optimize=True)
except Exception as e:
app.logger.warning(f"Thumbnail generation failed for {logo_path}: {e}")
thumb_filename = None
return (logo_filename, thumb_filename)
"""---------------------------------------------Invoice Generation----------------------------------------------------------------------------- """
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 _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 '-',
}
"""---------------------------------------------Notification System----------------------------------------------------------------------------- """
def _create_notification(db, *, audience, notif_type, title, message, target_user=None, reference=None, unique_key=None, severity='info'):
"""Create a notification entry with optional deduplication via unique_key."""
notifications_col = db['notifications']
if unique_key:
existing = notifications_col.find_one({'UniqueKey': unique_key}, {'_id': 1})
if existing:
return False
now = datetime.datetime.now()
payload = {
'Audience': audience,
'Type': notif_type,
'Title': title,
'Message': message,
'TargetUser': target_user,
'Reference': reference or {},
'UniqueKey': unique_key,
'Severity': severity,
'ReadBy': [],
'CreatedAt': now,
'UpdatedAt': now,
}
notifications_col.insert_one(payload)
if audience == 'user' and target_user:
_bump_notification_version(f'user:{target_user}')
# Send push notification to user
try:
pn.send_push_notification(
target_user,
title,
message,
url=reference.get('url', '/') if reference else '/',
reference=reference,
tag=f'notification-{notif_type}'
)
except Exception as e:
app.logger.warning(f'Failed to send push notification to {encrypt_text(target_user)}: {e}')
elif audience == 'admin':
_bump_notification_version('admin')
# Send push notification to all admins
try:
pn.send_push_to_all_admins(
title,
message,
url=reference.get('url', '/') if reference else '/',
reference=reference
)
except Exception as e:
app.logger.warning(f'Failed to send push to admins: {e}')
return True
def _get_notification_cache_client():
"""Return shared Redis client for distributed notification cache if available."""
global _NOTIFICATION_REDIS_CLIENT, _NOTIFICATION_REDIS_FAILED
if _NOTIFICATION_REDIS_CLIENT is not None:
return _NOTIFICATION_REDIS_CLIENT
if _NOTIFICATION_REDIS_FAILED or redis is None:
return None
try:
redis_host = os.getenv('INVENTAR_REDIS_HOST', 'redis')
redis_port = int(os.getenv('INVENTAR_REDIS_PORT', 6379))
redis_db = int(os.getenv('INVENTAR_REDIS_CACHE_DB', 1))
client = redis.Redis(
host=redis_host,
port=redis_port,
db=redis_db,
decode_responses=True,
socket_keepalive=True,
socket_timeout=1,
socket_connect_timeout=1,
)
client.ping()
_NOTIFICATION_REDIS_CLIENT = client
app.logger.info(f'Notification cache backend enabled: {redis_host}:{redis_port}/db{redis_db}')
return _NOTIFICATION_REDIS_CLIENT
except Exception as exc:
_NOTIFICATION_REDIS_FAILED = True
app.logger.warning(f'Notification cache backend unavailable, using local cache fallback: {exc}')
return None
def _notification_scope_key(scope):
return f'{_NOTIFICATION_CACHE_PREFIX}:ver:{scope}'
def _notification_status_key(username, is_admin, version_tag):
role = 'admin' if is_admin else 'user'
return f'{_NOTIFICATION_CACHE_PREFIX}:status:{role}:{username}:{version_tag}'
def _get_notification_version_tag(username, is_admin=False):
user_scope = f'user:{username}'
cache_client = _get_notification_cache_client()
if cache_client:
user_version = cache_client.get(_notification_scope_key(user_scope)) or '0'
if is_admin:
admin_version = cache_client.get(_notification_scope_key('admin')) or '0'
return f'u{user_version}-a{admin_version}'
return f'u{user_version}'
with _NOTIFICATION_CACHE_LOCK:
user_version = _NOTIFICATION_LOCAL_VERSIONS.get(user_scope, 0)
if is_admin:
admin_version = _NOTIFICATION_LOCAL_VERSIONS.get('admin', 0)
return f'u{user_version}-a{admin_version}'
return f'u{user_version}'
def _bump_notification_version(scope):
cache_client = _get_notification_cache_client()
if cache_client:
try:
cache_client.incr(_notification_scope_key(scope))
return
except Exception as exc:
app.logger.warning(f'Could not bump notification cache version for {scope}: {exc}')
with _NOTIFICATION_CACHE_LOCK:
current = int(_NOTIFICATION_LOCAL_VERSIONS.get(scope, 0))
_NOTIFICATION_LOCAL_VERSIONS[scope] = current + 1
# Prevent stale local payload reuse after version bump.
_NOTIFICATION_LOCAL_CACHE.clear()
def _get_cached_unread_status(username, is_admin=False):
version_tag = _get_notification_version_tag(username, is_admin=is_admin)
key = _notification_status_key(username, is_admin, version_tag)
cache_client = _get_notification_cache_client()
if cache_client:
try:
cached = cache_client.get(key)
if cached:
return json.loads(cached), version_tag
except Exception as exc:
app.logger.warning(f'Could not read notification status cache for {encrypt_text(username)}: {exc}')
return None, version_tag
now = time.time()
with _NOTIFICATION_CACHE_LOCK:
cached = _NOTIFICATION_LOCAL_CACHE.get(key)
if not cached:
return None, version_tag
if cached.get('expires_at', 0) <= now:
_NOTIFICATION_LOCAL_CACHE.pop(key, None)
return None, version_tag
return cached.get('payload'), version_tag
def _set_cached_unread_status(username, is_admin, version_tag, payload):
key = _notification_status_key(username, is_admin, version_tag)
cache_client = _get_notification_cache_client()
if cache_client:
try:
cache_client.setex(key, NOTIFICATION_STATUS_CACHE_TTL, json.dumps(payload, default=str))
return
except Exception as exc:
app.logger.warning(f'Could not write notification status cache for {encrypt_text(username)}: {exc}')
with _NOTIFICATION_CACHE_LOCK:
_NOTIFICATION_LOCAL_CACHE[key] = {
'expires_at': time.time() + NOTIFICATION_STATUS_CACHE_TTL,
'payload': payload,
}
def _build_unread_status_etag(version_tag, payload):
unread_count = int(payload.get('unread_count', 0))
latest = payload.get('latest_unread') or {}
latest_created = latest.get('created_at') or ''
latest_type = latest.get('type') or ''
return f'W/"notif-{version_tag}-{unread_count}-{latest_type}-{latest_created}"'
def _build_cached_json_response(payload, etag_value):
incoming_etag = (request.headers.get('If-None-Match') or '').strip()
if incoming_etag and incoming_etag == etag_value:
response = make_response('', 304)
else:
response = jsonify(payload)
response.headers['Cache-Control'] = f'private, max-age={NOTIFICATION_STATUS_CACHE_TTL}, must-revalidate'
response.headers['ETag'] = etag_value
response.headers['Vary'] = 'Cookie'
return response
def _get_notifications_for_user(db, username, is_admin=False, limit=150):
"""Fetch notifications visible to the current user, newest first."""
query = {
'$or': [
{'Audience': 'user', 'TargetUser': username},
]
}
if is_admin:
query['$or'].append({'Audience': 'admin'})
cursor = db['notifications'].find(query).sort('CreatedAt', -1).limit(limit)
return list(cursor)
def _get_unread_notification_count(db, username, is_admin=False):
"""Count unread notifications for navbar badge rendering."""
query = {
'$and': [
{
'$or': [
{'Audience': 'user', 'TargetUser': username},
]
},
{'ReadBy': {'$ne': username}},
]
}
if is_admin:
query['$and'][0]['$or'].append({'Audience': 'admin'})
return db['notifications'].count_documents(query)
def _build_reminder_message(item_name, start_dt=None, end_dt=None):
"""Build a concise reminder text for borrowed items."""
start_text = start_dt.strftime('%d.%m.%Y %H:%M') if isinstance(start_dt, datetime.datetime) else '-'
end_text = end_dt.strftime('%d.%m.%Y %H:%M') if isinstance(end_dt, datetime.datetime) else '-'
return (
f"Bitte denke an die Rueckgabe von '{item_name}'. "
f"Ausleihe seit: {start_text}. "
f"Geplantes Ende: {end_text}."
)
def create_return_reminders():
"""Create one-time reminders for day-1 and planned-end events."""
now = datetime.datetime.now()
one_day_ago = now - datetime.timedelta(days=1)
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
ausleihungen_col = db['ausleihungen']
items_col = db['items']
candidates = list(ausleihungen_col.find(
{
'Status': {'$in': ['active', 'planned']},
'User': {'$exists': True, '$ne': ''},
'$or': [
{'Start': {'$lte': one_day_ago}},
{'End': {'$lte': now}},
]
},
{'User': 1, 'Item': 1, 'Status': 1, 'Start': 1, 'End': 1}
))
item_ids = []
for entry in candidates:
item_id = entry.get('Item')
if item_id:
item_ids.append(item_id)
item_docs = {}
for raw_id in item_ids:
try:
doc = items_col.find_one({'_id': ObjectId(raw_id)}, {'Name': 1, 'Code_4': 1})
if doc:
item_docs[raw_id] = doc
except Exception:
continue
for borrow_doc in candidates:
borrow_id = str(borrow_doc.get('_id'))
username = str(borrow_doc.get('User', '')).strip()
if not borrow_id or not username:
continue
item_id = str(borrow_doc.get('Item', '')).strip()
item_doc = item_docs.get(item_id, {})
item_name = item_doc.get('Name') or f'Item {item_id}'
start_dt = borrow_doc.get('Start')
end_dt = borrow_doc.get('End')
if isinstance(start_dt, datetime.datetime) and start_dt <= one_day_ago:
_create_notification(
db,
audience='user',
notif_type='return_day_1',
title='Erinnerung: Rueckgabe nach 1 Tag',
message=_build_reminder_message(item_name, start_dt=start_dt, end_dt=end_dt),
target_user=username,
reference={'borrow_id': borrow_id, 'item_id': item_id, 'event': 'day_1'},
unique_key=f'reminder:day1:{borrow_id}',
severity='warning',
)
if isinstance(end_dt, datetime.datetime) and end_dt <= now:
_create_notification(
db,
audience='user',
notif_type='return_after_end',
title='Erinnerung: Geplante Ausleihe ist beendet',
message=_build_reminder_message(item_name, start_dt=start_dt, end_dt=end_dt),
target_user=username,
reference={'borrow_id': borrow_id, 'item_id': item_id, 'event': 'after_end'},
unique_key=f'reminder:end:{borrow_id}',
severity='warning',
)
except Exception as exc:
app.logger.warning(f"Reminder creation failed: {exc}")
finally:
if client:
client.close()
@app.context_processor
def inject_version():
"""Inject global template variables."""
is_admin = False
asset_version = APP_VERSION
csrf_token = _get_csrf_token()
unread_notification_count = 0
current_permissions = us.build_default_permission_payload('standard_user')
if 'username' in session:
try:
is_admin = us.check_admin(session['username'])
except Exception:
is_admin = False
try:
current_permissions = us.get_effective_permissions(session['username'])
except Exception:
current_permissions = us.build_default_permission_payload('standard_user')
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
unread_notification_count = _get_unread_notification_count(db, session['username'], is_admin=is_admin)
except Exception:
unread_notification_count = 0
finally:
if client:
client.close()
current_module = _get_current_module(request.path)
current_tenant_db = MONGODB_DB
current_tenant_id = None
try:
ctx = get_tenant_context()
if ctx:
current_tenant_db = ctx.db_name or current_tenant_db
current_tenant_id = ctx.tenant_id
except Exception:
current_tenant_db = MONGODB_DB
return {
'APP_VERSION': APP_VERSION,
'ASSET_VERSION': asset_version,
'csrf_token': csrf_token,
'CURRENT_MODULE': current_module,
'school_periods': SCHOOL_PERIODS,
'inventory_module_enabled': cfg.MODULES.is_enabled('inventory'),
'terminplan_module_enabled': cfg.MODULES.is_enabled('terminplan'),
'library_module_enabled': cfg.MODULES.is_enabled('library'),
'student_cards_module_enabled': cfg.MODULES.is_enabled('student_cards'),#
'mail_module_enabled': cfg.MODULES.is_enabled('mail'),
'is_admin': is_admin,
'unread_notification_count': unread_notification_count,
'current_permissions': current_permissions,
'current_tenant_db': current_tenant_db,
'current_tenant_id': current_tenant_id,
'school_info': _get_school_info_for_export(),
'permission_action_options': PERMISSION_ACTION_OPTIONS,
'permission_page_options': PERMISSION_PAGE_OPTIONS,
'permission_presets': us.get_permission_preset_definitions(),
'trial_status': session.get('trial_status', {}),
}
"""-------------------------------------------------------------Scheduled Tasks----------------------------------------------------------------------------- """
def create_daily_backup():
"""
Erstellt täglich ein Backup der Ausleihungsdatenbank
"""
try:
result = au.create_backup_database()
if not result:
app.logger.warning("Daily backup creation returned false")
except Exception as e:
app.logger.error(f"Daily backup creation failed: {e}")
def cleanup_expired_trial_tenants():
"""Remove expired trial tenants from config and MongoDB."""
try:
purged_tenants = purge_expired_trial_tenants()
if purged_tenants:
app.logger.warning(f"Purged expired trial tenants: {', '.join(purged_tenants)}")
except Exception as exc:
app.logger.error(f"Trial tenant cleanup failed: {exc}")
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()
try:
# 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')
activation_user = str(appointment.get('User') or '').strip()
activation_item_name = str(appointment.get('Item') or 'Termin')
# 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 = ''
item_name = item_id_str or 'Termin'
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:
item_name = item_doc.get('Name', item_id_str)
activation_item_name = item_name
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}"
)
app.logger.warning(conflict_log)
else:
# No conflict — clear any previously stored conflict flag
extra_fields['ConflictDetected'] = False
extra_fields['ConflictNote'] = ''
except Exception as conflict_err:
app.logger.warning(
f"Conflict check failed for appointment {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
# Make item unshareable if no conflict is detected
if old_status == 'planned' and appointment.get('Item') and not extra_fields.get('ConflictDetected', False):
try:
it.update_item_status(str(appointment.get('Item')), False, activation_user)
except Exception as e:
app.logger.warning(f"Could not update item status to False for {appointment['_id']}: {e}")
elif new_status == 'completed':
completed_count += 1
# Make item available again
if appointment.get('Item'):
try:
it.update_item_status(str(appointment.get('Item')), True)
except Exception as e:
app.logger.warning(f"Could not update item status to True for {appointment['_id']}: {e}")
# Create activation notification even if another worker already updated the status.
if old_status == 'planned' and new_status == 'active' and activation_user:
try:
_create_notification(
db,
audience='user',
notif_type='appointment_activated',
title='Reservierung ist jetzt aktiv',
message=(
f"Deine geplante Ausleihe für {activation_item_name} startet jetzt."
),
target_user=activation_user,
reference={
'appointment_id': str(appointment.get('_id')),
'item_id': str(appointment.get('Item') or ''),
'event': 'activated',
},
unique_key=f"appointment:activated:{appointment.get('_id')}",
severity='info',
)
except Exception as notif_err:
app.logger.warning(
f"Failed to create activation notification for {appointment.get('_id')}: {notif_err}"
)
client.close()
if updated_count > 0:
app.logger.warning(
f"Appointment status update finished: {updated_count} changed ({activated_count} active, {completed_count} completed)"
)
except Exception as e:
app.logger.error(f"Automatic appointment status update failed: {e}")
# Schedule jobs - only start scheduler if this is the main process or a single-worker deployment
# This prevents race conditions in multi-worker Gunicorn environments
scheduler = BackgroundScheduler()
_scheduler_initialized = False
def _initialize_scheduler():
"""Initialize the background scheduler in a safe way for multi-worker deployments."""
global _scheduler_initialized
if _scheduler_initialized or not cfg.SCHEDULER_ENABLED:
return
try:
# For multi-worker Gunicorn, use a lock file to ensure only one instance starts the scheduler
# Clean up any stale lock file from previous runs (older than 5 minutes)
scheduler_lock_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), '.scheduler_lock')
try:
if os.path.exists(scheduler_lock_path):
lock_age = time.time() - os.path.getmtime(scheduler_lock_path)
if lock_age > 300: # 5 minutes - indicates a stale lock from a previous container run
os.remove(scheduler_lock_path)
app.logger.info(f"Removed stale scheduler lock file (age: {lock_age:.0f}s)")
except Exception as e:
app.logger.warning(f"Could not clean up scheduler lock file: {e}")
# Always try to remove lock file on startup (extra safety)
try:
if os.path.exists(scheduler_lock_path):
os.remove(scheduler_lock_path)
app.logger.info("Scheduler lock file removed on startup.")
except Exception as e:
app.logger.warning(f"Could not remove scheduler lock file on startup: {e}")
try:
# Try to create the lock file - only succeeds if it doesn't exist
lock_fd = os.open(scheduler_lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
os.close(lock_fd)
should_start = True
except FileExistsError:
should_start = False
app.logger.warning("Scheduler lock exists - another process is already running the scheduler")
if should_start:
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.add_job(func=create_return_reminders, trigger="interval", minutes=cfg.SCHEDULER_INTERVAL_MIN)
scheduler.add_job(func=cleanup_expired_trial_tenants, trigger="interval", hours=1)
scheduler.start()
_scheduler_initialized = True
app.logger.info(f"Scheduler started successfully (interval={cfg.SCHEDULER_INTERVAL_MIN} min)")
else:
app.logger.info("Scheduler skipped - another worker instance is running it")
except Exception as e:
app.logger.error(f"Failed to initialize scheduler: {e}")
_scheduler_initialized = False
# Initialize scheduler on app startup
_initialize_scheduler()
# Register shutdown handler to stop scheduler when app is terminated
import atexit
def _shutdown_scheduler():
if cfg.SCHEDULER_ENABLED and _scheduler_initialized:
try:
scheduler.shutdown()
lock_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), '.scheduler_lock')
try:
os.remove(lock_path)
except Exception:
pass
except Exception as e:
app.logger.error(f"Error during scheduler shutdown: {e}")
atexit.register(_shutdown_scheduler)
"""-------------------------------------------------------------File Upload Validation----------------------------------------------------------------------------- """
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}"
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)}")
return False, f"Datei '{filename}' konnte nicht als Bild erkannt werden. "
# 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, ""
"""-------------------------------------------------------------Form Value Sanitization----------------------------------------------------------------------------- """
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 ''
def _normalize_excel_header(value):
"""Normalize Excel header labels for robust auto-mapping."""
if value is None:
return ''
text = str(value).strip().lower()
text = text.replace('ä', 'ae').replace('ö', 'oe').replace('ü', 'ue').replace('ß', 'ss')
text = re.sub(r'[^a-z0-9]+', '_', text)
text = re.sub(r'_+', '_', text).strip('_')
return text
def _excel_bool(value, default=True):
"""Parse a flexible Excel bool-like cell value."""
if value is None:
return default
if isinstance(value, bool):
return value
text = str(value).strip().lower()
if text in {'1', 'true', 'yes', 'y', 'ja', 'j', 'x'}:
return True
if text in {'0', 'false', 'no', 'n', 'nein'}:
return False
return default
def _excel_int(value):
"""Parse int-like values from Excel cells."""
if value is None or str(value).strip() == '':
return None
try:
return int(float(str(value).replace(',', '.')))
except Exception:
return None
def _excel_float(value):
"""Parse float-like values from Excel cells."""
if value is None or str(value).strip() == '':
return None
try:
return float(str(value).replace(',', '.'))
except Exception:
return None
def _excel_list(value):
"""Parse list-like filter values from comma/semicolon/newline separated strings."""
if value is None:
return []
if isinstance(value, list):
return [sanitize_form_value(v) for v in value if str(v).strip()]
text = str(value).strip()
if not text:
return []
parts = re.split(r'[;,\n\|]+', text)
cleaned = [sanitize_form_value(p.strip()) for p in parts if p and p.strip()]
# Deduplicate while preserving order.
seen = set()
unique = []
for entry in cleaned:
if entry not in seen:
unique.append(entry)
seen.add(entry)
return unique
def _load_tabular_upload(uploaded_file):
"""Load a CSV or XLSX upload and return header row plus data rows."""
filename = (getattr(uploaded_file, 'filename', '') or '').lower()
file_bytes = uploaded_file.read()
uploaded_file.stream.seek(0)
if filename.endswith('.csv'):
for encoding in ('utf-8-sig', 'utf-8', 'latin-1'):
try:
text = file_bytes.decode(encoding)
break
except Exception:
text = None
if text is None:
raise ValueError('CSV-Datei konnte nicht dekodiert werden.')
sample = text[:4096]
try:
dialect = csv.Sniffer().sniff(sample, delimiters=';,\t,|')
except Exception:
dialect = csv.excel
dialect.delimiter = ';' if sample.count(';') >= sample.count(',') else ','
reader = csv.reader(io.StringIO(text), dialect)
rows = [row for row in reader if any(str(cell).strip() for cell in row)]
if not rows:
raise ValueError('CSV-Datei enthält keine Daten.')
return rows[0], rows[1:]
try:
from openpyxl import load_workbook
except Exception as exc:
raise ValueError(f'Excel-Import benötigt openpyxl: {exc}') from exc
workbook = load_workbook(io.BytesIO(file_bytes), data_only=True, read_only=True)
sheet = workbook.active
header_row = next(sheet.iter_rows(min_row=1, max_row=1, values_only=True), None)
if not header_row:
workbook.close()
raise ValueError('Die Datei enthält keine Kopfzeile.')
data_rows = list(sheet.iter_rows(min_row=2, values_only=True))
workbook.close()
return header_row, data_rows
def _deny_if_unauthenticated_file_access():
"""Block file-serving routes unless a user is logged in."""
if 'username' not in session:
return Response('Forbidden', status=403)
return None
def _student_card_id_slug(value):
"""Build a compact identifier fragment from a name or class value."""
normalized = _normalize_excel_header(value)
if not normalized:
return ''
return re.sub(r'[^a-z0-9]+', '', normalized).upper()
"""-------------------------------------------------------------Filter----------------------------------------------------------------------------- """
FILTER_SELECT_ALL_TOKEN = '__ALL__'
def expand_filter_selection(selected_values, filter_num):
"""Expand special filter token into all predefined values for a filter."""
if not isinstance(selected_values, list):
return []
has_select_all = False
cleaned_values = []
seen = set()
for raw_value in selected_values:
value = str(raw_value).strip() if raw_value is not None else ''
if not value:
continue
if value == FILTER_SELECT_ALL_TOKEN:
has_select_all = True
continue
if value not in seen:
cleaned_values.append(value)
seen.add(value)
if not has_select_all:
return cleaned_values
predefined_values = it.get_predefined_filter_values(filter_num)
for predefined_value in predefined_values:
value = str(predefined_value).strip() if predefined_value is not None else ''
if value and value not in seen:
cleaned_values.append(value)
seen.add(value)
return cleaned_values
def _is_public_host(hostname):
"""Return True only for hosts that resolve to public IPs."""
if not hostname:
return False
hostname = hostname.strip().lower()
if hostname in {'localhost', '127.0.0.1', '::1'}:
return False
try:
resolved_infos = socket.getaddrinfo(hostname, None)
except Exception:
return False
public_seen = False
for info in resolved_infos:
address = info[4][0]
try:
ip_obj = ipaddress.ip_address(address)
except ValueError:
continue
if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local or ip_obj.is_multicast or ip_obj.is_reserved or ip_obj.is_unspecified:
return False
public_seen = True
return public_seen
"""-------------------------------------------------------------Student Cards Excel Import----------------------------------------------------------------------------- """
def _build_student_card_excel_id(student_name, class_name, row_number, used_ids):
"""Create a stable student-card ID without embedding personal names."""
class_slug = _student_card_id_slug(class_name)
base_parts = [part for part in (class_slug,) if part]
if base_parts:
base_id = f"SC-{'-'.join(base_parts[:1])}-ROW-{row_number}"
else:
base_id = f"SC-ROW-{row_number}"
candidate = base_id
suffix = 2
while candidate in used_ids:
candidate = f"{base_id}-{suffix}"
suffix += 1
used_ids.add(candidate)
return candidate
def _upload_student_cards_excel():
"""Bulk import student cards from Excel with automatic name/class mapping."""
if 'username' not in session:
flash('Nicht angemeldet.', 'error')
return redirect(url_for('login'))
if not us.check_admin(session['username']):
flash('Administratorrechte erforderlich.', 'error')
return redirect(url_for('home_admin'))
if not cfg.MODULES.is_enabled('student_cards'):
flash('Schülerausweis-Modul ist deaktiviert.', 'error')
return redirect(url_for('home_admin'))
excel_file = request.files.get('student_cards_excel')
if not excel_file or not excel_file.filename:
flash('Bitte eine Excel-Datei auswählen.', 'error')
return redirect(url_for('student_cards_admin'))
filename_lower = excel_file.filename.lower()
if not filename_lower.endswith(('.xlsx', '.csv')):
flash('Nur .xlsx oder .csv Dateien werden unterstützt.', 'error')
return redirect(url_for('student_cards_admin'))
try:
header_row, data_rows = _load_tabular_upload(excel_file)
except Exception as exc:
flash(f'Datei konnte nicht gelesen werden: {exc}', 'error')
return redirect(url_for('student_cards_admin'))
header_map = {}
for idx, raw_header in enumerate(header_row):
normalized = _normalize_excel_header(raw_header)
if normalized:
header_map[normalized] = idx
synonyms = {
'ausweis_id': ['ausweis_id', 'ausweisid', 'ausweis-id', 'karte', 'kartennummer', 'card_id', 'id'],
'student_name': ['student_name', 'schuelername', 'schülername', 'schueler', 'schüler', 'name', 'vollname', 'vorname_nachname', 'nachname_vorname'],
'first_name': ['vorname', 'first_name', 'firstname', 'rufname'],
'last_name': ['nachname', 'last_name', 'lastname'],
'class_name': ['klasse', 'class', 'class_name', 'jahrgang', 'jahrgangsstufe', 'stufe', 'gruppe', 'asv_klasse'],
'notes': ['notizen', 'notes', 'bemerkungen', 'bemerkung', 'hinweis', 'hinweise'],
'default_borrow_days': ['standard_ausleihdauer', 'ausleihdauer', 'borrow_days', 'tage', 'leihtage', 'max_borrow_days'],
}
def col_index(key):
for candidate in synonyms.get(key, []):
normalized = _normalize_excel_header(candidate)
if normalized in header_map:
return header_map[normalized]
return None
mapped_indices = {
'ausweis_id': col_index('ausweis_id'),
'student_name': col_index('student_name'),
'first_name': col_index('first_name'),
'last_name': col_index('last_name'),
'class_name': col_index('class_name'),
'notes': col_index('notes'),
'default_borrow_days': col_index('default_borrow_days'),
}
validation_only = (request.form.get('excel_action') or '').strip().lower() == 'validate'
max_rows = 15000
planned_rows = []
validation_errors = []
validation_warnings = []
existing_ids = set()
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
try:
db = client[cfg.MONGODB_DB]
student_cards = db['student_cards']
existing_ids.update(
str(card.get('AusweisId', '')).strip().upper()
for card in student_cards.find({}, {'AusweisId': 1})
if card.get('AusweisId')
)
processed_rows = 0
for row_number, row_values in enumerate(data_rows, start=2):
processed_rows += 1
if processed_rows > max_rows:
validation_errors.append((row_number, f'Maximal {max_rows} Zeilen pro Datei erlaubt'))
break
def val(key):
idx = mapped_indices.get(key)
if idx is None or idx >= len(row_values):
return None
return row_values[idx]
ausweis_id = sanitize_form_value(val('ausweis_id'))
student_name = sanitize_form_value(val('student_name'))
first_name = sanitize_form_value(val('first_name'))
last_name = sanitize_form_value(val('last_name'))
class_name = sanitize_form_value(val('class_name'))
notes = sanitize_form_value(val('notes'))
default_borrow_days = _excel_int(val('default_borrow_days')) or cfg.STUDENT_DEFAULT_BORROW_DAYS
if not student_name and first_name and last_name:
student_name = f'{first_name} {last_name}'.strip()
validation_warnings.append((row_number, 'Schülername wurde aus Vorname und Nachname zusammengesetzt'))
if not ausweis_id and not student_name and not class_name:
continue
row_errors = []
if not student_name:
row_errors.append('Schülername fehlt')
if not ausweis_id and student_name:
ausweis_id = _build_student_card_excel_id(student_name, class_name, row_number, existing_ids)
validation_warnings.append((row_number, f'Ausweis-ID wurde automatisch erzeugt: {ausweis_id}'))
elif ausweis_id:
ausweis_id = str(ausweis_id).strip().upper()
if ausweis_id in existing_ids:
row_errors.append(f'Ausweis-ID {ausweis_id} existiert bereits')
else:
existing_ids.add(ausweis_id)
if row_errors:
validation_errors.append((row_number, '; '.join(row_errors)))
continue
planned_rows.append({
'row_number': row_number,
'ausweis_id': ausweis_id,
'student_name': student_name,
'class_name': class_name,
'notes': notes,
'default_borrow_days': default_borrow_days,
})
finally:
client.close()
if validation_errors:
details = '; '.join([f'Zeile {n}: {msg}' for n, msg in validation_errors[:15]])
flash(f'Validierung fehlgeschlagen ({len(validation_errors)} Zeilen). {details}', 'error')
return redirect(url_for('student_cards_admin'))
if validation_only:
warning_text = ''
if validation_warnings:
warning_details = '; '.join([f'Zeile {n}: {msg}' for n, msg in validation_warnings[:10]])
warning_text = f' Hinweise: {warning_details}'
flash(f'Validierung erfolgreich: {len(planned_rows)} Ausweise würden importiert.{warning_text}', 'success')
return redirect(url_for('student_cards_admin'))
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
try:
db = client[cfg.MONGODB_DB]
student_cards = db['student_cards']
created_total = 0
for row in planned_rows:
encrypted_payload = encrypt_document_fields(
{
'SchülerName': row['student_name'],
'Klasse': row['class_name'],
'Notizen': row['notes'],
},
STUDENT_CARD_ENCRYPTED_FIELDS
)
student_cards.insert_one({
'AusweisId': row['ausweis_id'],
'StandardAusleihdauer': int(row['default_borrow_days']),
'Erstellt': datetime.datetime.now(),
**encrypted_payload,
})
created_total += 1
except Exception as exc:
app.logger.error(f'Error importing student cards from Excel: {exc}')
flash(f'Fehler beim Import der Bibliotheksausweise', 'error')
return redirect(url_for('student_cards_admin'))
finally:
client.close()
if validation_warnings:
warning_details = '; '.join([f'Zeile {n}: {msg}' for n, msg in validation_warnings[:10]])
flash(f'Excel-Import erfolgreich: {created_total} Ausweise importiert. Hinweise: {warning_details}', 'warning')
else:
flash(f'Excel-Import erfolgreich: {created_total} Ausweise importiert.', 'success')
return redirect(url_for('student_cards_admin'))
def _upload_excel_items(scope='inventory'):
"""Bulk import inventory/library items from Excel with validation-first workflow."""
if 'username' not in session:
flash('Nicht angemeldet.', 'error')
return redirect(url_for('login'))
is_library_scope = scope == 'library'
file_field = 'library_excel' if is_library_scope else 'inventory_excel'
fallback_route = 'library_admin' if is_library_scope else 'upload_admin'
if is_library_scope:
if not cfg.MODULES.is_enabled('library'):
flash('Bibliotheks-Modul ist deaktiviert.', 'error')
return redirect(url_for('home'))
excel_file = request.files.get(file_field)
if not excel_file or not excel_file.filename:
flash('Bitte eine Excel- oder CSV-Datei auswählen.', 'error')
return redirect(url_for(fallback_route))
# Permission handling: allow authenticated normal users to import CSV for inventory scope
permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user')
has_insert = _action_access_allowed(permissions, 'can_insert')
filename_lower = (excel_file.filename or '').lower()
is_csv = filename_lower.endswith('.csv')
if not has_insert:
# Allow CSV imports for authenticated non-admin users only for inventory (non-library)
if not (is_csv and not is_library_scope and 'username' in session):
flash('Einfüge-Rechte erforderlich.', 'error')
return redirect(url_for('home'))
filename_lower = excel_file.filename.lower()
if not filename_lower.endswith(('.xlsx', '.csv')):
flash('Nur .xlsx oder .csv Dateien werden unterstützt.', 'error')
return redirect(url_for(fallback_route))
try:
header_row, data_rows = _load_tabular_upload(excel_file)
except Exception as exc:
flash(f'Datei konnte nicht gelesen werden: {exc}', 'error')
return redirect(url_for(fallback_route))
header_map = {}
for idx, raw_header in enumerate(header_row):
normalized = _normalize_excel_header(raw_header)
if normalized:
header_map[normalized] = idx
synonyms = {
'name': ['name', 'titel', 'artikel', 'item', 'bezeichnung'],
'ort': ['ort', 'location', 'standort', 'platz'],
'beschreibung': ['beschreibung', 'description', 'desc', 'details'],
'filter1': ['filter1', 'filter', 'fach', 'unterrichtsfach', 'kategorie1', 'category1'],
'filter2': ['filter2', 'jahrgang', 'jahrgangsstufe', 'klasse', 'kategorie2', 'category2'],
'filter3': ['filter3', 'bereich', 'gruppe', 'typ', 'kategorie3', 'category3'],
'anschaffungsjahr': ['anschaffungsjahr', 'jahr', 'year'],
'anschaffungskosten': ['anschaffungskosten', 'kosten', 'cost', 'preis', 'price'],
'code_4': ['code_4', 'code4', 'code', 'inventarnummer', 'inventar_nr', 'id_code'],
'reservierbar': ['reservierbar', 'reservable', 'bookable'],
'anzahl': ['anzahl', 'menge', 'quantity', 'count'],
'isbn': ['isbn'],
'item_type': ['item_type', 'typ', 'type'],
}
def col_index(key):
for candidate in synonyms.get(key, []):
normalized = _normalize_excel_header(candidate)
if normalized in header_map:
return header_map[normalized]
return None
required_columns = {'name': col_index('name'), 'ort': col_index('ort'), 'beschreibung': col_index('beschreibung')}
missing_required = [k for k, v in required_columns.items() if v is None]
if missing_required:
missing_label = ', '.join(missing_required)
flash(f'Pflichtspalten fehlen in Excel: {missing_label}', 'error')
return redirect(url_for(fallback_route))
mapped_indices = {
'name': col_index('name'),
'ort': col_index('ort'),
'beschreibung': col_index('beschreibung'),
'filter1': col_index('filter1'),
'filter2': col_index('filter2'),
'filter3': col_index('filter3'),
'anschaffungsjahr': col_index('anschaffungsjahr'),
'anschaffungskosten': col_index('anschaffungskosten'),
'code_4': col_index('code_4'),
'reservierbar': col_index('reservierbar'),
'anzahl': col_index('anzahl'),
'isbn': col_index('isbn'),
'item_type': col_index('item_type'),
}
validation_only = (request.form.get('excel_action') or '').strip().lower() == 'validate'
max_rows = 15000
max_generated_items = 50000
max_items_per_row = 1000
# Validation phase: parse + map + validate every row before any write.
planned_rows = []
validation_errors = []
validation_warnings = []
reserved_codes = set()
known_locations = set(it.get_predefined_locations())
known_filters = {
1: set(it.get_predefined_filter_values(1)),
2: set(it.get_predefined_filter_values(2)),
3: set(it.get_predefined_filter_values(3))
}
def is_code_available(candidate_code):
return candidate_code not in reserved_codes and it.is_code_unique(candidate_code)
def resolve_unique_code(base_candidate):
if is_code_available(base_candidate):
reserved_codes.add(base_candidate)
return base_candidate
suffix = 1
while suffix <= 1000:
alt = f"{base_candidate}-{suffix}"
if is_code_available(alt):
reserved_codes.add(alt)
return alt
suffix += 1
return None
processed_rows = 0
planned_item_total = 0
row_limit_exceeded = False
for row_number, row_values in enumerate(data_rows, start=2):
processed_rows += 1
if processed_rows > max_rows:
row_limit_exceeded = True
break
def val(key):
idx = mapped_indices.get(key)
if idx is None or idx >= len(row_values):
return None
return row_values[idx]
name = sanitize_form_value(val('name'))
ort = sanitize_form_value(val('ort'))
beschreibung = sanitize_form_value(val('beschreibung'))
if not name and not ort and not beschreibung:
# Fully empty row.
continue
row_errors = []
if not name or not ort or not beschreibung:
row_errors.append('Pflichtfeld Name/Ort/Beschreibung fehlt')
if ort and ort not in known_locations:
validation_warnings.append((row_number, f'Ort "{ort}" wird neu angelegt'))
filter1 = expand_filter_selection(_excel_list(val('filter1')), 1)
for f in filter1:
if f and f not in known_filters[1]:
validation_warnings.append((row_number, f'Kategorie 1 "{f}" wird neu angelegt'))
filter2 = expand_filter_selection(_excel_list(val('filter2')), 2)
for f in filter2:
if f and f not in known_filters[2]:
validation_warnings.append((row_number, f'Kategorie 2 "{f}" wird neu angelegt'))
filter3 = _excel_list(val('filter3'))
for f in filter3:
if f and f not in known_filters[3]:
validation_warnings.append((row_number, f'Kategorie 3 "{f}" wird neu angelegt'))
anschaffungsjahr = _excel_int(val('anschaffungsjahr'))
raw_year = val('anschaffungsjahr')
if raw_year not in (None, '') and anschaffungsjahr is None:
row_errors.append('Anschaffungsjahr ist kein gültiger Integer')
anschaffungskosten = _excel_float(val('anschaffungskosten'))
raw_cost = val('anschaffungskosten')
if raw_cost not in (None, '') and anschaffungskosten is None:
row_errors.append('Anschaffungskosten sind keine gültige Zahl')
reservierbar = _excel_bool(val('reservierbar'), default=True)
count = _excel_int(val('anzahl')) or 1
raw_count = val('anzahl')
if raw_count not in (None, '') and _excel_int(raw_count) is None:
row_errors.append('Anzahl ist kein gültiger Integer')
count = max(1, min(count, max_items_per_row))
raw_code = val('code_4')
base_code = sanitize_form_value(str(raw_code).strip()) if raw_code is not None and str(raw_code).strip() else None
item_isbn = ''
raw_isbn = val('isbn')
if raw_isbn:
item_isbn = normalize_and_validate_isbn(str(raw_isbn))
if not item_isbn:
row_errors.append('ISBN ist ungültig (erwartet ISBN-10/13)')
if is_library_scope and not item_isbn:
row_errors.append('Für den Bibliotheksimport ist eine gültige ISBN erforderlich')
raw_item_type = sanitize_form_value(str(val('item_type')).strip().lower()) if val('item_type') else ''
item_type = 'book' if is_library_scope else (raw_item_type or ('book' if item_isbn else 'general'))
planned_codes = []
if base_code:
for position in range(1, count + 1):
candidate = base_code if position == 1 else f"{base_code}-{position}"
code = resolve_unique_code(candidate)
if not code:
row_errors.append(f'Code konnte nicht eindeutig erzeugt werden ({candidate})')
break
planned_codes.append(code)
else:
planned_codes = [None] * count
if row_errors:
validation_errors.append((row_number, '; '.join(row_errors)))
continue
planned_rows.append({
'row_number': row_number,
'name': name,
'ort': ort,
'beschreibung': beschreibung,
'filter1': filter1,
'filter2': filter2,
'filter3': filter3,
'anschaffungsjahr': anschaffungsjahr,
'anschaffungskosten': anschaffungskosten,
'reservierbar': reservierbar,
'count': count,
'codes': planned_codes,
'isbn': item_isbn,
'item_type': item_type,
})
planned_item_total += count
if planned_item_total > max_generated_items:
row_limit_exceeded = True
break
if row_limit_exceeded:
flash(
f'Importgrenze überschritten. Maximal erlaubt: {max_rows} Zeilen und {max_generated_items} erzeugte Artikel pro Datei.',
'error'
)
return redirect(url_for(fallback_route))
if validation_errors:
details = '; '.join([f'Zeile {n}: {msg}' for n, msg in validation_errors[:15]])
flash(f'Validierung fehlgeschlagen ({len(validation_errors)} Zeilen). {details}', 'error')
return redirect(url_for(fallback_route))
if validation_only:
warning_text = ''
if validation_warnings:
warning_details = '; '.join([f'Zeile {n}: {msg}' for n, msg in validation_warnings[:10]])
warning_text = f' Hinweise: {warning_details}'
planned_count = sum(row['count'] for row in planned_rows)
flash(f'Validierung erfolgreich: {len(planned_rows)} Zeilen geprüft, {planned_count} Artikel würden importiert.{warning_text}', 'success')
return redirect(url_for(fallback_route))
# Import phase: only runs after successful validation.
created_total = 0
import_errors = []
for row in planned_rows:
if row['ort'] and row['ort'] not in known_locations:
it.add_predefined_location(row['ort'])
known_locations.add(row['ort'])
for f in row.get('filter1', []):
if f and f not in known_filters[1]:
it.add_predefined_filter_value(1, f)
known_filters[1].add(f)
for f in row.get('filter2', []):
if f and f not in known_filters[2]:
it.add_predefined_filter_value(2, f)
known_filters[2].add(f)
for f in row.get('filter3', []):
if f and f not in known_filters[3]:
it.add_predefined_filter_value(3, f)
known_filters[3].add(f)
series_group_id = str(uuid.uuid4()) if row['count'] > 1 else None
row_created_ids = []
for position in range(1, row['count'] + 1):
parent_item_id = str(row_created_ids[0]) if row_created_ids else None
code = row['codes'][position - 1] if position - 1 < len(row['codes']) else None
item_id = it.add_item(
name=row['name'],
ort=row['ort'],
beschreibung=row['beschreibung'],
images=[],
filter=row['filter1'],
filter2=row['filter2'],
filter3=row['filter3'],
ansch_jahr=row['anschaffungsjahr'],
ansch_kost=row['anschaffungskosten'],
code_4=code,
reservierbar=row['reservierbar'],
series_group_id=series_group_id,
series_count=row['count'],
series_position=position,
is_grouped_sub_item=(position > 1),
parent_item_id=parent_item_id,
isbn=row['isbn'],
item_type=row['item_type'],
is_library=False
)
if not item_id:
import_errors.append((row['row_number'], 'Fehler beim Anlegen des Artikels'))
break
row_created_ids.append(item_id)
created_total += len(row_created_ids)
if created_total == 0:
details = '; '.join([f'Zeile {n}: {msg}' for n, msg in import_errors[:10]])
flash(f'Keine Artikel importiert. {details}', 'error')
return redirect(url_for(fallback_route))
if import_errors:
flash(f'Excel-Import abgeschlossen: {created_total} Artikel importiert, {len(import_errors)} Zeilen fehlgeschlagen.', 'warning')
else:
flash(f'Excel-Import erfolgreich: {created_total} Artikel importiert.', 'success')
if is_library_scope:
try:
_append_audit_event_standalone(
event_type='library_bulk_import',
payload={
'channel': 'upload_library_excel',
'created_total': created_total,
'processed_rows': len(planned_rows),
}
)
except Exception:
app.logger.warning('Audit write failed for library_bulk_import')
return redirect(url_for('home_admin'))
@app.route('/upload_inventory_excel', methods=['POST'])
def upload_inventory_excel():
"""Bulk import for non-library inventory."""
return _upload_excel_items(scope='inventory')
@app.route('/upload_library_excel', methods=['POST'])
def upload_library_excel():
"""Bulk import dedicated to library items (ISBN required)."""
return _upload_excel_items(scope='library')
@app.route('/upload_student_cards_excel', methods=['POST'])
def upload_student_cards_excel():
"""Bulk import student cards from Excel."""
return _upload_student_cards_excel()
"""-------------------------------------------------------------File Serving-----------------------------------------------------------------------------"""
@app.route('/uploads/<filename>')
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:
denied = _deny_if_unauthenticated_file_access()
if denied:
return denied
# 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:
app.logger.error(f"Error serving file {filename}: {str(e)}")
return Response("Image not found", status=404)
@app.route('/thumbnails/<filename>')
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:
denied = _deny_if_unauthenticated_file_access()
if denied:
return denied
# 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:
app.logger.error(f"Error serving thumbnail {filename}: {str(e)}")
return Response("Thumbnail not found", status=404)
@app.route('/previews/<filename>')
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:
denied = _deny_if_unauthenticated_file_access()
if denied:
return denied
dev_path = app.config['PREVIEW_FOLDER']
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:
app.logger.error(f"Error serving preview {filename}: {str(e)}")
return Response("Preview not found", status=404)
@app.route('/image/optimized/<filename>')
def optimized_image(filename):
"""
Serve optimized images at 480p maximum resolution (854px width).
Images are cached and converted to WebP for maximum compression.
This endpoint minimizes server RAM usage and bandwidth.
Args:
filename (str): Original image filename
Returns:
flask.Response: Optimized image (WebP preferred, JPEG fallback) or placeholder
"""
try:
denied = _deny_if_unauthenticated_file_access()
if denied:
return denied
# Sanitize filename to prevent directory traversal
filename = os.path.basename(filename)
name_part, ext_part = os.path.splitext(filename)
# Determine cache directory (use unique subdirectory for 480p optimized images)
cache_dir = app.config['THUMBNAIL_FOLDER'] # Reuse existing directory structure
cache_subdir = os.path.join(cache_dir, 'optimized_480p')
os.makedirs(cache_subdir, exist_ok=True)
# Try to find the cached optimized image first (WebP preferred)
cached_webp = os.path.join(cache_subdir, f"{name_part}_480p.webp")
if os.path.exists(cached_webp):
response = send_from_directory(cache_subdir, f"{name_part}_480p.webp")
response.headers['Cache-Control'] = 'public, max-age=2592000, immutable' # 30 days
response.headers['Content-Type'] = 'image/webp'
return response
# Try cached JPEG fallback
cached_jpeg = os.path.join(cache_subdir, f"{name_part}_480p.jpg")
if os.path.exists(cached_jpeg):
response = send_from_directory(cache_subdir, f"{name_part}_480p.jpg")
response.headers['Cache-Control'] = 'public, max-age=2592000, immutable' # 30 days
response.headers['Content-Type'] = 'image/jpeg'
return response
# Find the original image
original_paths = [
os.path.join(app.config['UPLOAD_FOLDER'], filename),
os.path.join("/var/Inventarsystem/Web/uploads", filename),
os.path.join(app.config['UPLOAD_FOLDER'], f"{name_part}.webp"),
os.path.join("/var/Inventarsystem/Web/uploads", f"{name_part}.webp"),
]
original_image_path = None
for path in original_paths:
if os.path.exists(path):
original_image_path = path
break
# If original image not found, serve placeholder
if not original_image_path:
svg_placeholder = os.path.join(app.static_folder, 'img', 'no-image.svg')
if os.path.exists(svg_placeholder):
return send_from_directory(app.static_folder, 'img/no-image.svg')
return send_from_directory(app.static_folder, 'img/no-image.png')
# Skip if it's not a supported image format
if not is_image_file(original_image_path):
return send_from_directory(app.static_folder, 'img/no-image.png')
# Create optimized version (480p = ~854px width max)
MAX_WIDTH = 854
MAX_HEIGHT = 480
try:
with Image.open(original_image_path) as img:
# Normalize orientation (fix EXIF rotation)
img = normalize_image_orientation(img)
# Resize maintaining aspect ratio
img.thumbnail((MAX_WIDTH, MAX_HEIGHT), Image.Resampling.LANCZOS)
# Try to save as WebP first (best compression)
try:
img = img.convert('RGB') if img.mode in ('RGBA', 'P') else img
img.save(cached_webp, 'WEBP', quality=80, method=6) # Quality 80, slowest method for best compression
response = send_from_directory(cache_subdir, f"{name_part}_480p.webp")
response.headers['Cache-Control'] = 'public, max-age=2592000, immutable' # 30 days
response.headers['Content-Type'] = 'image/webp'
return response
except Exception as webp_err:
app.logger.warning(f"WebP encoding failed for {filename}, falling back to JPEG: {str(webp_err)}")
# Fallback to JPEG if WebP fails
img = img.convert('RGB') if img.mode in ('RGBA', 'P', 'L') else img
img.save(cached_jpeg, 'JPEG', quality=75, optimize=True) # Quality 75, optimized
response = send_from_directory(cache_subdir, f"{name_part}_480p.jpg")
response.headers['Cache-Control'] = 'public, max-age=2592000, immutable' # 30 days
response.headers['Content-Type'] = 'image/jpeg'
return response
except Exception as img_err:
app.logger.error(f"Error processing image {filename}: {str(img_err)}")
return send_from_directory(app.static_folder, 'img/no-image.png')
except Exception as e:
app.logger.error(f"Error serving optimized image {filename}: {str(e)}")
return Response("Optimized image not found", status=404)
# @app.route('/QRCodes/<filename>')
# 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('/<path:filename>')
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:
denied = _deny_if_unauthenticated_file_access()
if denied:
return denied
# 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')
]
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 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:
app.logger.error(f"Error in catch-all route for {filename}: {str(e)}")
return Response(f"Error serving file {filename}", status=500)
"""-------------------------------------------------------------Main Views-----------------------------------------------------------------------------"""
@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
"""
if 'username' not in session or not us.check_admin(session['username']):
return {'status': 'forbidden'}, 403
return {'status': 'success', 'message': 'Connection successful', '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'))
if not cfg.MODULES.is_enabled('inventory'):
if cfg.MODULES.is_enabled('library'):
return redirect('/library')
else:
return "Weder Inventar- noch Bibliotheks-Modul sind aktiviert.", 403
elif not us.check_admin(session['username']):
return render_template(
'main.html',
username=session['username'],
library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
mail_module_enabled=cfg.MODULES.is_enabled('mail'),
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS,
open_item=request.args.get('open_item')
)
else:
permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user')
if _page_access_allowed(permissions, 'home_admin') and _action_access_allowed(permissions, 'can_manage_settings'):
return redirect(url_for('home_admin'))
fallback_endpoint = _permission_denied_fallback_endpoint(permissions, current_endpoint='home')
if fallback_endpoint == 'logout':
flash('Für diesen Benutzer sind aktuell keine Seiten freigegeben.', 'error')
return redirect(url_for(fallback_endpoint))
@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 cfg.MODULES.is_enabled('inventory'):
if cfg.MODULES.is_enabled('library'):
return redirect(url_for('library_admin'))
else:
return "Weder Inventar- noch Bibliotheks-Modul sind aktiviert.", 403
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.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
mail_module_enabled=cfg.MODULES.is_enabled('mail'),
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS,
school_info=_get_school_info_for_export(),
open_item=request.args.get('open_item')
)
@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.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
mail_module_enabled=cfg.MODULES.is_enabled('mail'),
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS
)
@app.route('/library/export', defaults={'scope': 'all'})
@app.route('/library/export/<scope>')
def library_export_excel(scope='all'):
if 'username' not in session:
return redirect(url_for('login'))
username = session['username']
is_admin_user = us.check_admin(username)
# Base query
query = {
'ItemType': {'$in': LIBRARY_ITEM_TYPES},
'Deleted': {'$ne': True}
}
# Scope modifications
if scope == 'borrowed_by_me':
query['User'] = username
query['Verfuegbar'] = False
filename = f"Bibliothek_Ausgeliehen_{username}.xlsx"
elif scope == 'all_borrowed':
if not is_admin_user:
# Consider adding a flash message here before redirecting
return redirect('/library')
query['Verfuegbar'] = False
filename = "Bibliothek_Alle_Ausleihen.xlsx"
elif scope == 'schulbuecher':
# Overwrites the base $in query, which is fine
query['ItemType'] = 'schulbuch'
filename = "Schulbuecher_Bestand.xlsx"
elif scope == 'all_books':
# FIX: Keep the library item types restriction, but exclude 'general'
query['ItemType'] = {'$in': [t for t in LIBRARY_ITEM_TYPES if t != 'general']}
filename = "Bibliothek_Buecher.xlsx"
else:
# 'all' scope
filename = "Bibliothek_Gesamtbestand.xlsx"
# Database interaction
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items_collection = db['items']
items = list(items_collection.find(query))
client.close()
# Generate and send file
excel_file = excel_export.generate_library_excel(items)
return send_file(
excel_file,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=filename
)
@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.MODULES.is_enabled('library'):
flash('Bibliotheks-Modul ist deaktiviert.', 'error')
return redirect(url_for('home'))
return render_template(
'library_table.html',
username=session['username'],
library_module_enabled=cfg.MODULES.is_enabled('library'),
is_admin=us.check_admin(session['username']),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
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.MODULES.is_enabled('library'):
flash('Bibliotheks-Modul ist deaktiviert.', 'error')
return redirect(url_for('home_admin'))
_ensure_audit_indexes_once()
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}, 'Deleted': {'$ne': True}},
{'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 '',
'invoice_corrections_count': len(record.get('InvoiceCorrections', []) or []),
'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.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
)
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 library items (books, CDs, DVDs, media).
Supports pagination via query params: offset, limit.
"""
if 'username' not in session:
return jsonify({'items': []}), 401
offset_raw = request.args.get('offset', '0')
limit_raw = request.args.get('limit', '120')
try:
offset = max(0, int(offset_raw))
except (TypeError, ValueError):
offset = 0
try:
limit = int(limit_raw)
except (TypeError, ValueError):
limit = 120
limit = min(max(limit, 1), 500)
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
items_db = db['items']
ausleihungen_db = db['ausleihungen']
query = {
'ItemType': {'$in': ['book', 'cd', 'dvd', 'other', 'schoolbook', 'schulbuch', 'Buch', 'Schulbuch']},
'IsGroupedSubItem': {'$ne': True},
'Deleted': {'$ne': True}
}
projection = {
'Name': 1,
'Autor': 1,
'Author': 1,
'ISBN': 1,
'Code_4': 1,
'Code4': 1,
'ItemType': 1,
'Verfuegbar': 1,
'Condition': 1,
'HasDamage': 1,
'User': 1,
'Ort': 1,
'Beschreibung': 1,
'Image': 1
}
total_count = items_db.count_documents(query)
raw_items = list(items_db.find(query, projection).sort([('Name', 1), ('_id', 1)]).skip(offset).limit(limit))
# Build maps for grouping by parent-child relationship (grouped sub-items)
parent_ids_list = [str(itm.get('_id')) for itm in raw_items if itm.get('_id')]
children_by_parent = {}
child_items = []
if parent_ids_list:
child_projection = {
'Name': 1,
'Autor': 1,
'Author': 1,
'ISBN': 1,
'Code_4': 1,
'Code4': 1,
'ItemType': 1,
'Verfuegbar': 1,
'Condition': 1,
'HasDamage': 1,
'User': 1,
'Ort': 1,
'Beschreibung': 1,
'Image': 1,
'ParentItemId': 1,
}
child_items = list(items_db.find({
'ParentItemId': {'$in': parent_ids_list},
'IsGroupedSubItem': True,
'Deleted': {'$ne': True}
}, child_projection))
for child in child_items:
parent_id = str(child.get('ParentItemId') or '')
if not parent_id:
continue
children_by_parent.setdefault(parent_id, []).append(child)
all_ids = parent_ids_list + [str(child.get('_id')) for child in child_items if child.get('_id')]
active_records = []
if all_ids:
active_records = list(ausleihungen_db.find({'Item': {'$in': all_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', '')
# Build aggregated list: iterate ordered parent list and include children like inventory module
aggregated = []
for parent in raw_items:
pid = str(parent.get('_id'))
children = children_by_parent.get(pid, [])
# Compute grouped counts and availability
grouped_units = [parent] + children
grouped_all_codes = []
available_units = []
for unit in grouped_units:
unit_id = str(unit.get('_id'))
code = unit.get('Code_4') or unit.get('Code4') or ''
if code:
grouped_all_codes.append(code)
if unit.get('Verfuegbar', True):
available_units.append({'id': unit_id, 'code': code, 'label': f"{code} ({unit.get('Name','')})"})
# Convert ObjectId to string for JSON serialization on parent doc copy
doc = dict(parent)
doc['_id'] = str(parent.get('_id'))
if doc.get('Code4') in (None, '') and doc.get('Code_4') not in (None, ''):
doc['Code4'] = doc.get('Code_4')
condition_value = str(doc.get('Condition', '')).strip().lower()
has_damage = bool(doc.get('HasDamage')) or condition_value == 'destroyed'
has_active_borrow = any(str(unit.get('_id')) in active_item_ids for unit in grouped_units)
# Prefer 'available' when any grouped unit is available
if has_damage and len(available_units) == 0:
doc['LibraryDisplayStatus'] = 'damaged'
elif len(available_units) > 0:
doc['LibraryDisplayStatus'] = 'available'
elif has_active_borrow or doc.get('Verfuegbar') is False:
doc['LibraryDisplayStatus'] = 'borrowed'
else:
doc['LibraryDisplayStatus'] = 'available'
# Determine borrower: prefer any active borrow on grouped units, fallback to parent.User
borrower = ''
for unit in grouped_units:
u_id = str(unit.get('_id'))
if active_user_by_item.get(u_id):
borrower = active_user_by_item.get(u_id)
break
if not borrower:
borrower = doc.get('User', '')
# Decrypt if value is encrypted (decrypt_text returns original if not encrypted)
try:
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(borrow_id)})
card = _decrypt_student_card_doc(card)
client.close()
borrower = card['SchülerName']
except Exception:
# Fallback: keep original string if decryption fails
borrower = borrower or ''
doc['BorrowedBy'] = borrower
doc['GroupedDisplayCount'] = 1 + len(children)
doc['Quantity'] = doc['GroupedDisplayCount']
doc['AvailableGroupedCount'] = len(available_units)
doc['GroupedAvailableUnits'] = available_units
doc['GroupedAllCodes'] = grouped_all_codes
aggregated.append(doc)
client.close()
count = len(aggregated)
return jsonify({
'items': aggregated,
'offset': offset,
'limit': limit,
'count': count,
'total': total_count,
'has_more': (offset + count) < total_count
}), 200
except Exception as e:
app.logger.error(f"Error fetching library items: {e}")
return jsonify({'error': 'An error occurred while fetching library items'}), 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.MODULES.is_enabled('library'):
return jsonify({'ok': False, 'message': 'Bibliotheks-Modul ist deaktiviert.'}), 403
if not cfg.MODULES.is_enabled('student_cards'):
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
card_doc = _decrypt_student_card_doc(card_doc)
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)
_append_audit_event_standalone(
event_type='ausleihung_borrowed',
payload={
'channel': 'library_scan',
'item_id': item_id,
'item_name': item_doc.get('Name', ''),
'borrower':borrower_name,
'student_card_id': student_card_id,
'borrow_duration_days': borrow_duration_days,
}
)
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)
_append_audit_event_standalone(
event_type='ausleihung_returned',
payload={
'channel': 'library_scan',
'item_id': item_id,
'item_name': item_doc.get('Name', ''),
'borrower': borrower_name,
'student_card_id': student_card_id,
'completed_records': update_result.modified_count,
}
)
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/<item_id>')
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']
items_col = db['items']
# Determine parent/group id (if this is a child, use its ParentItemId)
parent_id = str(item.get('ParentItemId') or str(item.get('_id')))
# Load all group units (parent + children)
group_units_cursor = items_col.find({
'$or': [
{'_id': ObjectId(parent_id)},
{'ParentItemId': parent_id}
],
'Deleted': {'$ne': True}
})
group_units = list(group_units_cursor)
if not group_units:
# Fallback: just use the single item
group_units = [item]
group_unit_ids = [str(u.get('_id')) for u in group_units if u.get('_id')]
# Fetch all borrow records for the whole group (active + history)
borrow_records = list(ausleihungen_col.find({'Item': {'$in': group_unit_ids}}).sort('Start', -1))
# Compute availability and damage state across group
available_units = [u for u in group_units if u.get('Verfuegbar', True)]
condition_value = str(item.get('Condition', '')).strip().lower()
has_damage = bool(item.get('HasDamage')) or condition_value == 'destroyed'
if has_damage and len(available_units) == 0:
status_label = 'Defekt/Zerstört'
elif len(available_units) > 0:
status_label = 'Verfügbar'
elif any(r.get('Status') == 'active' for r in borrow_records):
status_label = 'Ausgeliehen'
else:
status_label = 'Verfügbar'
# Prefer the most recent active borrower across the group
borrower_value = ''
for rec in borrow_records:
if rec.get('Status') == 'active' and rec.get('User'):
try:
user_raw = rec.get('User')
if user_raw is not None:
borrower_value = decrypt_text(str(user_raw))
except Exception:
borrower_value = str(rec.get('User', ''))
break
if not borrower_value and item.get('User'):
try:
item_user = item.get('User')
if item_user is not None:
borrower_value = decrypt_text(str(item_user))
except Exception:
borrower_value = str(item.get('User', ''))
# Helper to format datetimes
def fmt_dt(dt):
try:
return dt.strftime('%d.%m.%Y %H:%M') if dt else ''
except Exception:
return str(dt) if dt else ''
borrows_html = ''
if borrow_records:
rows = []
for rec in borrow_records:
user_raw = rec.get('User')
try:
user = decrypt_text(user_raw) if user_raw is not None else ''
except Exception:
user = user_raw
status = rec.get('Status', '')
start = fmt_dt(rec.get('Start'))
end = fmt_dt(rec.get('End'))
notes = html.escape(str(rec.get('Notes') or ''))
rows.append(
f"<li><strong>{html.escape(str(user or '-'))}</strong> — "
f"{html.escape(str(status))} — "
f"{html.escape(str(start))}{html.escape(str(end))}"
f"{(' — ' + notes) if notes else ''}</li>"
)
borrows_html = f"<h3>Ausleihhistorie</h3><ul>{''.join(rows)}</ul>"
detail_html = f"""
<h2>{html.escape(str(item.get('Name', 'Untitled')))}</h2>
<p><strong>ISBN:</strong> {html.escape(str(item.get('ISBN', item.get('Code4', '-'))))}</p>
<p><strong>Anzahl:</strong> {html.escape(str(item.get('SeriesCount', '-')))}</p>
<p><strong>Ort:</strong> {html.escape(str(item.get('Ort', '-')))}</p>
<p><strong>Typ:</strong> {html.escape(str(item.get('ItemType', '-')))}</p>
<p><strong>Kategorie:</strong> {html.escape(str(item.get('library_category', '-')))}</p>
<p><strong>Beschreibung:</strong> {html.escape(str(item.get('Beschreibung', '-')))}</p>
<p><strong>Status:</strong> {html.escape(str(status_label))}</p>
{f'<p><strong>Ausgeliehen von:</strong> {html.escape(str(borrower_value))}</p>' if borrower_value and status_label == 'Ausgeliehen' else ''}
{borrows_html}
"""
client.close()
return detail_html, 200
except Exception as e:
app.logger.error(f"Error fetching item detail: {e}")
return jsonify({'error': 'An error occurred while fetching the item detail'}), 500
@app.route('/api/library_item/<item_id>/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.MODULES.is_enabled('library'):
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})
try:
_append_audit_event_standalone(
event_type='library_item_updated',
payload={
'channel': 'library_table',
'item_id': item_id,
'updated_fields': list(update_doc.keys()),
'isbn': update_doc.get('ISBN', ''),
}
)
except Exception:
app.logger.warning('Audit write failed for library_item_updated')
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():
"""
Upload page route for inventory items.
Accessible to users with insert permission.
Supports duplication by passing duplicate_from parameter.
Returns:
flask.Response: Rendered template or redirect
"""
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'))
permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user')
if not _action_access_allowed(permissions, 'can_insert'):
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
return redirect(url_for('login'))
# Check if this is a duplication request
duplicate_from = request.args.get('duplicate_from')
duplicate_flag = request.args.get('duplicate') # Check for sessionStorage-based duplication
duplicate_data = None
# Handle the old method (duplicate_from parameter with item ID)
if duplicate_from:
try:
original_item = it.get_item(duplicate_from)
if original_item:
duplicate_data = {
'name': original_item.get('Name', ''),
'description': original_item.get('Beschreibung', ''),
'location': original_item.get('Ort', ''),
'room': original_item.get('Raum', ''),
'category': original_item.get('Kategorie', ''),
'year': original_item.get('Anschaffungsjahr', ''),
'cost': original_item.get('Anschaffungskosten', ''),
'filter1': original_item.get('Filter1', ''),
'filter2': original_item.get('Filter2', ''),
'filter3': original_item.get('Filter3', ''),
'images': original_item.get('Images', []),
'original_id': duplicate_from
}
# Copy all filter fields (Filter1_1 through Filter3_5)
for i in range(1, 4): # Filter1, Filter2, Filter3
for j in range(1, 6): # _1 through _5
filter_key = f'Filter{i}_{j}'
if filter_key in original_item:
duplicate_data[f'filter{i}_{j}'] = original_item[filter_key]
flash('Element wird dupliziert. Bitte überprüfen Sie die Daten und passen Sie sie bei Bedarf an.', 'info')
else:
flash('Ursprungs-Element für Duplizierung nicht gefunden.', 'error')
except Exception as e:
app.logger.warning(f"Error loading item for duplication: {e}")
flash('Fehler beim Laden der Duplizierungsdaten.', 'error')
# Handle the new method (sessionStorage-based duplication)
elif duplicate_flag == 'true':
# No server-side processing needed - JavaScript will handle sessionStorage data
# Just indicate that duplication mode is active
flash('Element wird dupliziert. Die Daten werden aus dem Session-Speicher geladen.', 'info')
return render_template(
'upload_admin.html',
username=session['username'],
duplicate_data=duplicate_data,
library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
show_library_features=False,
upload_mode='item',
page_title='Artikel hochladen',
back_target='home_admin'
)
@app.route('/library_admin')
def library_admin():
"""
Dedicated page for library/book uploads with ISBN scanning.
Accessible to users with insert permission 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'))
permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user')
if not _action_access_allowed(permissions, 'can_insert'):
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.MODULES.is_enabled('library'):
flash('Bibliotheks-Modul ist deaktiviert.', 'error')
return redirect(url_for('home_admin'))
duplicate_from = request.args.get('duplicate_from')
duplicate_flag = request.args.get('duplicate')
duplicate_data = None
if duplicate_from:
try:
original_item = it.get_item(duplicate_from)
if original_item:
duplicate_data = {
'name': original_item.get('Name', ''),
'description': original_item.get('Beschreibung', ''),
'location': original_item.get('Ort', ''),
'room': original_item.get('Raum', ''),
'category': original_item.get('Kategorie', ''),
'year': original_item.get('Anschaffungsjahr', ''),
'cost': original_item.get('Anschaffungskosten', ''),
'filter1': original_item.get('Filter1', ''),
'filter2': original_item.get('Filter2', ''),
'filter3': original_item.get('Filter3', ''),
'images': original_item.get('Images', []),
'original_id': duplicate_from
}
for i in range(1, 4):
for j in range(1, 6):
filter_key = f'Filter{i}_{j}'
if filter_key in original_item:
duplicate_data[f'filter{i}_{j}'] = original_item[filter_key]
flash('Buch wird dupliziert. Bitte überprüfen Sie die Daten und passen Sie sie bei Bedarf an.', 'info')
else:
flash('Ursprungs-Element für Duplizierung nicht gefunden.', 'error')
except Exception as e:
app.logger.warning(f"Error loading item for duplication: {e}")
flash('Fehler beim Laden der Duplizierungsdaten.', 'error')
elif duplicate_flag == 'true':
flash('Buch wird dupliziert. Die Daten werden aus dem Session-Speicher geladen.', 'info')
return render_template(
'upload_admin.html',
username=session['username'],
duplicate_data=duplicate_data,
library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
show_library_features=True,
upload_mode='library',
page_title='Bücher hochladen',
back_target='library'
)
@app.route('/student_cards_admin', methods=['GET', 'POST'])
def student_cards_admin():
"""
Admin page for managing student library cards (Bibliotheksausweis).
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.MODULES.is_enabled('student_cards'):
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:
card = _decrypt_student_card_doc(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:
app.logger.error(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()
student_name_alias = student_name
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:
app.logger.error(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:
encrypted_payload = encrypt_document_fields(
{
'SchülerName': student_name_alias,
'Klasse': class_name,
'Notizen': notes,
},
STUDENT_CARD_ENCRYPTED_FIELDS
)
student_cards.update_one(
{'_id': ObjectId(card_id)},
{'$set': {
'AusweisId': ausweis_id,
'StandardAusleihdauer': int(default_borrow_days),
'Aktualisiert': datetime.datetime.now(),
**encrypted_payload
}}
)
flash('Ausweis wurde aktualisiert.', 'success')
return redirect(url_for('student_cards_admin'))
except Exception as e:
app.logger.error(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:
encrypted_payload = encrypt_document_fields(
{
'SchülerName': student_name_alias,
'Klasse': class_name,
'Notizen': notes,
},
STUDENT_CARD_ENCRYPTED_FIELDS
)
student_cards.insert_one({
'AusweisId': ausweis_id,
'StandardAusleihdauer': int(default_borrow_days),
'Erstellt': datetime.datetime.now(),
**encrypted_payload,
})
flash('Neuer Ausweis wurde hinzugefügt.', 'success')
return redirect(url_for('student_cards_admin'))
except Exception as e:
app.logger.error(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))
all_cards = [_decrypt_student_card_doc(card) for card in all_cards]
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.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards')
)
@app.route('/student_cards_print', methods=['GET'])
def student_cards_print():
"""
Generate a printable template for all student library cards (Bibliotheksausweis).
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.MODULES.is_enabled('student_cards'):
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))
all_cards = [_decrypt_student_card_doc(card) for card in all_cards]
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.MODULES.is_enabled('student_cards'):
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))
all_cards = [_decrypt_student_card_doc(card) for card in all_cards]
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.MODULES.is_enabled('student_cards'):
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))
all_cards = [_decrypt_student_card_doc(card) for card in all_cards]
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)
school_name = cfg.get_school_info()
school_name = school_name["name"]
# "SCHÜLERAUSWEIS" text in header
c.setFont("Helvetica-Bold", 9)
c.setFillColor(white)
c.drawString(x_pos + 3*mm, y_pos - 6.5*mm, f"SCHÜLERAUSWEIS - {str(school_name)}")
# 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'])
c.drawString(x_pos + 3*mm, y_pos - 31*mm, "-")
c.drawString(x_pos + 3*mm, y_pos - 34*mm, "-")
c.drawString(x_pos + 3*mm, y_pos - 37*mm, "-")
c.drawString(x_pos + 3*mm, y_pos - 40*mm, "-")
# 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:
app.logger.error(f"Error occurred while generating PDF for card {card['AusweisId']}: {e}")
flash('Fehler beim PDF-Download', 'error')
return redirect(url_for('student_cards_admin'))
@app.route('/student_card_single_barcode_download/<card_id>', 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.MODULES.is_enabled('student_cards'):
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)})
card = _decrypt_student_card_doc(card)
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)
school_name = cfg.get_school_info()
school_name = school_name["name"]
# "SCHÜLERAUSWEIS" text in header
c.setFont("Helvetica-Bold", 11)
c.setFillColor(white)
c.drawString(x_pos + 4*mm, y_pos - 6.5*mm, f"SCHÜLERAUSWEIS - {str(school_name)}")
# 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:
app.logger.error(f"Error occurred while generating PDF for card {card['AusweisId']}: {e}")
flash('Fehler beim PDF-Download', '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']
ctx = get_tenant_context()
current_tenant_id = ctx.tenant_id if ctx else None
current_tenant_db = ctx.db_name if ctx else cfg.MONGODB_DB
app.logger.info(f"Login attempt: username={encrypt_text(username)!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={encrypt_text(request.remote_addr)}")
app.logger.info(f"Active MongoDB config: uri={getattr(cfg, 'MONGODB_URI', None)!r} host={cfg.MONGODB_HOST!r} port={cfg.MONGODB_PORT!r} default_db={cfg.MONGODB_DB!r}")
if not username or not password:
app.logger.warning(f"Login blocked: missing credentials tenant={current_tenant_id or 'default'} host={request.host} ip={encrypt_text(request.remote_addr)}")
flash('Bitte alle Felder ausfüllen', 'error')
return redirect(url_for('login'))
user = us.check_nm_pwd(username, password)
if user:
app.logger.info(f"Login success: username={encrypt_text(username)!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={encrypt_text(request.remote_addr)}")
session['username'] = username
is_admin_user = bool(user.get('Admin', False))
session['admin'] = is_admin_user
session['is_admin'] = is_admin_user
# Bind session favorites to the authenticated user to avoid cross-user leakage.
try:
session['favorites_owner'] = username
session['favorites'] = list(dict.fromkeys([str(f) for f in us.get_favorites(username)]))
except Exception:
session['favorites_owner'] = username
session['favorites'] = []
if is_admin_user:
permissions = us.get_effective_permissions(username)
if _page_access_allowed(permissions, 'home_admin') and _action_access_allowed(permissions, 'can_manage_settings'):
return redirect(url_for('home_admin'))
fallback_endpoint = _permission_denied_fallback_endpoint(permissions, current_endpoint='login')
return redirect(url_for(fallback_endpoint))
else:
return redirect(url_for('home'))
else:
app.logger.warning(f"Login failed: username={encrypt_text(username)!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={encrypt_text(request.remote_addr)}")
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)
session.pop('favorites', None)
session.pop('favorites_owner', None)
session.pop('tenant_id', 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."""
client = None
try:
_ensure_session_favs()
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', []))
available_only = str(request.args.get('available_only', '')).strip().lower() in ('1', 'true', 'yes', 'on')
offset_raw = request.args.get('offset')
limit_raw = request.args.get('limit')
light_mode_param = str(request.args.get('light_mode', '')).strip().lower()
pagination_requested = offset_raw is not None or limit_raw is not None
if pagination_requested:
try:
offset = max(0, int(offset_raw or '0'))
except (TypeError, ValueError):
offset = 0
try:
limit = int(limit_raw or '120')
except (TypeError, ValueError):
limit = 120
limit = min(max(limit, 1), 500)
else:
offset = 0
limit = None
if light_mode_param in ('1', 'true', 'yes', 'on'):
light_mode = True
elif light_mode_param in ('0', 'false', 'no', 'off'):
light_mode = False
else:
light_mode = (offset == 0)
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
items_col = db['items']
base_query = {
'IsGroupedSubItem': {'$ne': True},
'ItemType': {'$nin': LIBRARY_ITEM_TYPES},
'Deleted': {'$ne': True},
}
total_count = items_col.count_documents(base_query)
light_projection = {
'Name': 1,
'Code_4': 1,
'Images': 1,
'ThumbnailInfo': 1,
'Verfuegbar': 1,
'Filter': 1,
'Filter2': 1,
'Filter3': 1,
'Ort': 1,
'User': 1,
'BlockedNow': 1,
'Reservierbar': 1,
'HasDamage': 1,
'ItemType': 1,
}
# Full projection: all details for detailed view
full_projection = {
'Name': 1,
'Ort': 1,
'Beschreibung': 1,
'Filter': 1,
'Filter2': 1,
'Filter3': 1,
'Code_4': 1,
'Images': 1,
'ThumbnailInfo': 1,
'Verfuegbar': 1,
'User': 1,
'BorrowerInfo': 1,
'appointments': 1,
'BlockedNow': 1,
'Reservierbar': 1,
'DamageReports': 1,
'ISBN': 1,
'Author': 1,
'Autor': 1,
'Anschaffungsjahr': 1,
'Anschaffungskosten': 1,
'Condition': 1,
'HasDamage': 1,
'ItemType': 1,
'SeriesGroupId': 1,
'LastUpdated': 1,
}
parent_projection = light_projection if light_mode else full_projection
items_cur = items_col.find(base_query, parent_projection).sort([('Name', 1), ('_id', 1)])
if pagination_requested:
items_cur = items_cur.skip(offset).limit(limit)
parent_items = list(items_cur)
parent_ids = [str(item.get('_id')) for item in parent_items if item.get('_id') is not None]
children_by_parent = {}
if parent_ids:
# Light mode: minimal child data for counting only
light_child_projection = {
'_id': 1,
'ParentItemId': 1,
'Code_4': 1,
'Verfuegbar': 1,
'Name': 1,
}
# Full mode: complete child data with all details
full_child_projection = {
'_id': 1,
'ParentItemId': 1,
'Code_4': 1,
'Verfuegbar': 1,
'Name': 1,
'Images': 1,
'ThumbnailInfo': 1,
'Beschreibung': 1,
}
child_projection = light_child_projection if light_mode else full_child_projection
child_cursor = items_col.find({
'ParentItemId': {'$in': parent_ids},
'IsGroupedSubItem': True,
'Deleted': {'$ne': True},
}, child_projection)
for child in child_cursor:
parent_id = str(child.get('ParentItemId') or '')
if not parent_id:
continue
children_by_parent.setdefault(parent_id, []).append(child)
items = []
for itm in parent_items:
item_id_str = str(itm['_id'])
grouped_children = children_by_parent.get(item_id_str, [])
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
if available_only and not itm.get('Verfuegbar', False):
continue
itm['is_favorite'] = item_id_str in favorites
items.append(itm)
count = len(items)
return jsonify({
'items': items,
'favorites': list(favorites),
'offset': offset,
'limit': limit if limit is not None else count,
'count': count,
'total': total_count,
'light_mode': light_mode,
'has_more': pagination_requested and ((offset + count) < total_count)
})
except Exception as e:
return jsonify({'items': []}), 500
finally:
if client:
client.close()
@app.route('/get_item/<id>')
def get_item_json(id):
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
item = db['items'].find_one({'_id': ObjectId(id), 'Deleted': {'$ne': True}})
if not item:
return jsonify({'error': 'not found'}), 404
item['_id'] = str(item['_id'])
return jsonify(item)
except Exception as e:
app.logger.error(f"Error occurred while fetching item {id}: {e}")
return jsonify({'error': 'An error occurred while fetching the item'}), 500
@app.route('/get_bookings')
def get_bookings():
"""Return calendar bookings for the current user session."""
if 'username' not in session:
return jsonify({'ok': False, 'error': 'unauthorized'}), 401
client = None
try:
username = session.get('username')
start = request.args.get('start')
end = request.args.get('end')
bookings = au.get_ausleihungen(status=['planned', 'active', 'completed'], start=start, end=end)
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
items_col = db['items']
result = []
for booking in bookings:
start_dt = booking.get('Start')
if not start_dt:
continue
end_dt = booking.get('End')
if not end_dt and isinstance(start_dt, datetime.datetime):
end_dt = start_dt + datetime.timedelta(minutes=45)
elif not end_dt:
end_dt = start_dt
item_id = str(booking.get('Item') or '')
item_doc = None
if item_id:
try:
item_doc = items_col.find_one({'_id': ObjectId(item_id)})
except Exception:
item_doc = None
item_name = item_id or 'Ausleihe'
item_borrower = ''
if item_doc:
item_name = item_doc.get('Name') or item_doc.get('Code_4') or item_name
borrower_info = item_doc.get('BorrowerInfo') or {}
borrower_name = borrower_info.get('User') if isinstance(borrower_info, dict) else ''
item_borrower = str(item_doc.get('User') or borrower_name or '')
status = booking.get('Status') or 'unknown'
if status == 'active':
status = 'current'
period = booking.get('Period')
title = item_name
if period:
title = f"{title} - {period}. Std"
result.append({
'id': str(booking.get('_id')),
'title': title,
'start': start_dt.isoformat() if isinstance(start_dt, datetime.datetime) else str(start_dt),
'end': end_dt.isoformat() if isinstance(end_dt, datetime.datetime) else str(end_dt),
'status': status,
'itemId': item_id,
'userName': str(booking.get('User') or ''),
'notes': str(booking.get('Notes') or ''),
'period': period,
'isCurrentUser': str(booking.get('User') or '') == username,
'itemBorrower': item_borrower,
})
return jsonify({'ok': True, 'bookings': result})
except Exception as e:
return jsonify({'ok': False, 'bookings': []}), 500
finally:
if client:
client.close()
@app.route('/get_user_appointments')
def get_user_appointments():
"""Return the current user's planned and active appointments for the calendar."""
if 'username' not in session:
return jsonify({'ok': False, 'error': 'unauthorized'}), 401
client = None
try:
username = session.get('username')
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
items_col = db['items']
# Use the appointment collection for the user's appointments
appointments = termin.get_upcoming_for_user(username, limit=250)
result = []
import re as _re
def _date_iter(start_value: str, end_value: str):
try:
start_date = datetime.datetime.strptime(start_value, '%Y-%m-%d').date()
end_date = datetime.datetime.strptime(end_value, '%Y-%m-%d').date()
except Exception:
return []
if end_date < start_date:
end_date = start_date
cursor = start_date
days = []
while cursor <= end_date:
days.append(cursor.strftime('%Y-%m-%d'))
cursor += datetime.timedelta(days=1)
return days
for appt in appointments:
appt_id = str(appt.get('_id') or '')
if not appt_id:
continue
date_start = str(appt.get('date_start') or '')
date_end = str(appt.get('date_end') or date_start)
time_span = appt.get('time_span', []) or []
title = appt.get('note') or f"Termin von {appt.get('user') or ''}"
days_in_range = _date_iter(date_start, date_end) or ([date_start] if date_start else [])
span_entries = []
for entry in time_span:
s = str(entry or '').strip()
if not s:
continue
m_date = _re.match(r"^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})-(\d{2}:\d{2})$", s)
if m_date:
span_entries.append((m_date.group(1), m_date.group(2), m_date.group(3)))
continue
m = _re.match(r"^(\d{2}:\d{2})-(\d{2}:\d{2})$", s)
if m:
for day in days_in_range:
span_entries.append((day, m.group(1), m.group(2)))
# If the stored span is already day-specific, use it as-is.
# If it was generic, we duplicated it to each day in the range above.
if not span_entries and days_in_range:
# Fallback: create a simple all-day marker for each date so the appointment is visible.
for day in days_in_range:
span_entries.append((day, '08:00', '16:45'))
for day, start_time, end_time in span_entries:
result.append({
'id': f"{appt_id}-{day}-{start_time}",
'title': title,
'start': f"{day}T{start_time}",
'end': f"{day}T{end_time}",
'status': 'planned',
'itemId': appt_id,
'userName': str(appt.get('user') or ''),
'notes': str(appt.get('note') or ''),
'period': None,
'isCurrentUser': True,
'itemBorrower': '',
})
return jsonify({'ok': True, 'bookings': result})
except Exception as e:
return jsonify({'ok': False, 'bookings': []}), 500
finally:
if client:
client.close()
@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:
app.logger.error(f"Error occurred while fetching booking conflicts: {e}")
return jsonify({'error': 'An error occurred while fetching booking conflicts', 'conflicts': []}), 500
"""Favorites management endpoints (persistent + session cache)."""
def _ensure_session_favs():
username = session.get('username')
owner = session.get('favorites_owner')
if not username:
if 'favorites' not in session or not isinstance(session.get('favorites'), list):
session['favorites'] = []
return
if owner != username:
session['favorites_owner'] = username
session['favorites'] = []
session.modified = True
return
if 'favorites' not in session or not isinstance(session.get('favorites'), list):
session['favorites'] = []
session.modified = True
@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/<item_id>', 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/<item_id>', 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/<item_id>', 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."""
_ensure_session_favs()
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:
app.logger.error(f"Error fetching DB favorites: {e}")
return jsonify({'ok': False, 'error': 'Failed to fetch DB favorites', '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
"""
# Check if the user is authenticated
if 'username' not in session:
return jsonify({'success': False, 'message': 'Nicht angemeldet'}), 401
item_is_library = False
# Check if user may insert items
username = session['username']
permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user')
if not _action_access_allowed(permissions, 'can_insert'):
return jsonify({'success': False, 'message': 'Einfüge-Rechte erforderlich'}), 403
can_access_admin_home = _page_access_allowed(permissions, 'home_admin') and _action_access_allowed(permissions, 'can_manage_settings')
if can_access_admin_home:
success_redirect_endpoint = 'home_admin'
elif cfg.MODULES.is_enabled('library') and _page_access_allowed(permissions, 'home_library'):
success_redirect_endpoint = 'home_library'
else:
success_redirect_endpoint = 'home'
# 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 {encrypt_text(username)}")
try:
# Strip whitespace from all text fields
name = sanitize_form_value(request.form['name'])
ort = sanitize_form_value(request.form['ort'])
beschreibung = sanitize_form_value(request.form['beschreibung'])
# Check both possible image field names
images = request.files.getlist('images') or request.files.getlist('new_images')
filter_upload = sanitize_form_value(request.form.getlist('filter'))
filter_upload2 = sanitize_form_value(request.form.getlist('filter2'))
filter_upload3 = sanitize_form_value(request.form.getlist('filter3'))
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'))
item_type_input = sanitize_form_value(request.form.get('item_type_input', ''))
library_category = sanitize_form_value(request.form.get('library_category', ''))
if library_category != '':
item_is_library = True
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 []
# 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"
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(success_redirect_endpoint))
# Expand special "all values" selections for predefined filters.
filter_upload = expand_filter_selection(filter_upload, 1)
filter_upload2 = expand_filter_selection(filter_upload2, 2)
# 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(success_redirect_endpoint))
item_isbn = ''
item_type = 'other'
if cfg.MODULES.is_enabled('library') and isbn_raw:
normalized_isbn = normalize_and_validate_isbn(isbn_raw)
if normalized_isbn:
item_isbn = normalized_isbn
item_type = 'book'
else:
item_isbn = isbn_raw
if item_type_input != "":
item_type = item_type_input
if upload_mode == 'library':
if not cfg.MODULES.is_enabled('library'):
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(success_redirect_endpoint))
if not isbn_raw:
error_msg = 'Für Bücher ist eine ISBN oder ein Barcode erforderlich.'
if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400
flash(error_msg, 'error')
return redirect(url_for('library_admin'))
# Only check for images if not duplicating and no duplicate images provided and no book cover
# For library mode, skip this check as images come only from ISBN fetch
if upload_mode != 'library' and not is_duplicating and not images and not duplicate_images and not book_cover_image:
error_msg = 'Bitte laden Sie mindestens ein Bild hoch'
if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400
else:
flash(error_msg, 'error')
return redirect(url_for(success_redirect_endpoint))
# -------------------------------------------------------------------
# 1. PARSE AND UNIFY CODES
# -------------------------------------------------------------------
# Assuming `code_4` might come in as a single string or a list depending on your form setup
primary_code_raw = request.form.get('code_4', '').strip()
individual_codes_raw = request.form.get('individual_codes', '').strip()
all_item_codes = []
if primary_code_raw:
all_item_codes.append(primary_code_raw)
if individual_codes_raw:
# Split textarea by newlines, clean whitespace, and ignore empty lines
extra_codes = [c.strip() for c in individual_codes_raw.replace('\r', '').split('\n') if c.strip()]
# Add to master list, ensuring no duplicates within the submitted list itself
for c in extra_codes:
if c not in all_item_codes:
all_item_codes.append(c)
# -------------------------------------------------------------------
# 2. OVERRIDE ITEM COUNT
# -------------------------------------------------------------------
# If the user scanned/generated codes, the amount of codes IS the item count.
# Otherwise, fallback to a manual item_count field if it exists.
if len(all_item_codes) > 0:
item_count = len(all_item_codes)
else:
try:
item_count = int(request.form.get('item_count', 1))
except ValueError:
item_count = 1
# -------------------------------------------------------------------
# 3. IMAGE VALIDATION
# -------------------------------------------------------------------
if upload_mode != 'library' and not is_duplicating and not images and not duplicate_images and not book_cover_image:
error_msg = 'Bitte laden Sie mindestens ein Bild hoch'
if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400
else:
flash(error_msg, 'error')
return redirect(url_for(success_redirect_endpoint))
# -------------------------------------------------------------------
# 4. UNIFIED DB UNIQUENESS VALIDATION
# -------------------------------------------------------------------
# Validate every code in our master list against the database
for code in all_item_codes:
if not it.is_code_unique(code):
app.logger.info(f"DEBUG: Code '{code}' is not unique.")
# Special case: If they only uploaded ONE item, redirect them to that existing item
if item_count == 1:
existing_item = it._get_items_collection().find_one({"code_4": code})
if existing_item:
error_msg = 'Der Code wird bereits verwendet. Umleitung zum Eintrag.'
if is_mobile:
return jsonify({
'success': False,
'message': error_msg,
'existing_item_id': str(existing_item['_id']),
'redirect_to_item': True
}), 400
flash(error_msg, 'info')
return redirect(url_for(success_redirect_endpoint, open_item=str(existing_item['_id'])))
# If multiple items are being uploaded, just throw a standard error
error_msg = f'Der Code "{code}" wird bereits von einem anderen Artikel verwendet. Bitte überprüfen Sie Ihre Eingaben.'
if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400
else:
flash(error_msg, 'error')
return redirect(url_for(success_redirect_endpoint))
# -------------------------------------------------------------------
# 5. BATCH CODE GENERATOR (Remains mostly unchanged)
# -------------------------------------------------------------------
def generate_unique_batch_code(base_code, position):
"""Generate a unique code for every item in a batch if no specific code is provided."""
if not base_code:
return None
candidate = base_code if position == 1 else f"{base_code}"
if it.is_code_unique(candidate):
return candidate
suffix = 1
while suffix <= 1000:
alternative = f"{candidate}"
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: {encrypt_text(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__}")
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)}")
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__}")
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)}")
# 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)}")
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}
# 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)}")
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}")
# 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(success_redirect_endpoint))
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,
library_category=library_category,
is_library=item_is_library
)
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(success_redirect_endpoint))
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 upload_mode == 'library':
try:
_append_audit_event_standalone(
event_type='library_item_created',
payload={
'channel': 'upload_admin',
'created_item_ids': created_item_ids,
'created_count': len(created_item_ids),
'isbn': item_isbn,
'codes': created_item_ids and [it.get_item(cid).get('Code_4') for cid in created_item_ids] or []
}
)
except Exception:
app.logger.warning('Audit write failed for library_item_created')
flash(success_msg, 'success')
return redirect(url_for(success_redirect_endpoint, 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(success_redirect_endpoint))
@app.route('/duplicate_item', methods=['POST'])
def duplicate_item():
"""
Route for duplicating an existing item.
Returns JSON response with success status.
Enhanced for mobile browser compatibility.
Returns:
flask.Response: JSON response with success status and data
"""
try:
# Check authentication
if 'username' not in session:
return jsonify({'success': False, 'message': 'Nicht angemeldet'}), 401
# Check if user is admin
username = session['username']
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 {encrypt_text(username)}")
# Get original item ID
original_item_id = request.form.get('original_item_id')
if not original_item_id:
return jsonify({'success': False, 'message': 'Ursprungs-Element-ID fehlt'}), 400
# Fetch original item data
original_item = it.get_item(original_item_id)
if not original_item:
return jsonify({'success': False, 'message': 'Ursprungs-Element nicht gefunden'}), 404
# Process filters as arrays (same as stored in database)
filter1_array = original_item.get('Filter', [])
filter2_array = original_item.get('Filter2', [])
filter3_array = original_item.get('Filter3', [])
# Ensure filters are arrays
if not isinstance(filter1_array, list):
filter1_array = [filter1_array] if filter1_array else []
if not isinstance(filter2_array, list):
filter2_array = [filter2_array] if filter2_array else []
if not isinstance(filter3_array, list):
filter3_array = [filter3_array] if filter3_array else []
# Verify image paths for mobile devices to avoid issues with non-existent images
images = original_item.get('Images', [])
verified_images = []
if is_mobile:
for img in images:
img_path = os.path.join(app.config['UPLOAD_FOLDER'], img)
if os.path.exists(img_path) and os.path.isfile(img_path):
verified_images.append(img)
else:
app.logger.warning(f"Image not found for duplication: {img}")
# If we lost images in verification, log it
if len(verified_images) < len(images):
app.logger.warning(f"Only {len(verified_images)} of {len(images)} images verified for mobile duplication")
else:
verified_images = images
# For iOS devices, add more diagnostics and reduce data size if needed
if is_ios:
# Check if images exist (we now use main images as thumbnails)
images_exist = []
for img in verified_images[:5]: # Only check first 5 to save time
img_path = os.path.join(app.config['UPLOAD_FOLDER'], img)
if os.path.exists(img_path):
images_exist.append(True)
else:
images_exist.append(False)
# Log detailed diagnostics
app.logger.info(f"iOS duplication details: {len(verified_images)} images, "
f"images available: {all(images_exist)}, "
f"filter sizes: {len(filter1_array)}, {len(filter2_array)}, {len(filter3_array)}")
return jsonify({
'success': True,
'message': 'Duplizierungsdaten erfolgreich vorbereitet',
'item_data': {
'name': original_item.get('Name', ''),
'description': original_item.get('Beschreibung', ''),
'location': original_item.get('Ort', ''),
'room': original_item.get('Raum', ''),
'category': original_item.get('Kategorie', ''),
'year': original_item.get('Anschaffungsjahr', ''),
'cost': original_item.get('Anschaffungskosten', ''),
'filter1': filter1_array,
'filter2': filter2_array,
'filter3': filter3_array,
'images': verified_images, # Using verified images instead of original
'isMobile': is_mobile,
'isIOS': is_ios
}
})
except Exception as e:
print(f"Error in duplicate_item: {e}")
return jsonify({'success': False, 'message': 'Serverfehler beim Duplizieren'}), 500
def _soft_delete_item_groups(db, root_item_ids, username):
"""Soft-delete one or more item groups and their borrow records."""
now = datetime.datetime.now()
unique_group_item_ids = []
seen_ids = set()
for root_item_id in root_item_ids:
for group_item_id in it.get_group_item_ids(root_item_id):
if group_item_id not in seen_ids:
seen_ids.add(group_item_id)
unique_group_item_ids.append(group_item_id)
if not unique_group_item_ids:
return {
'success': False,
'message': 'Element nicht gefunden.',
'soft_deleted_items': 0,
'soft_deleted_borrows': 0,
'group_item_ids': [],
'archive': {
'archive_created': False,
'archived_files': 0,
'deleted_files': 0,
'archive_path': None,
},
}
object_ids = []
for group_item_id in unique_group_item_ids:
try:
object_ids.append(ObjectId(group_item_id))
except Exception:
continue
item_docs_for_archive = []
if object_ids:
item_docs_for_archive = list(db['items'].find(
{'_id': {'$in': object_ids}},
{'_id': 1, 'Images': 1}
))
delete_success = True
soft_deleted_items = 0
for group_item_id in unique_group_item_ids:
result = db['items'].update_one(
{'_id': ObjectId(group_item_id), 'Deleted': {'$ne': True}},
{'$set': {
'Deleted': True,
'DeletedAt': now,
'DeletedBy': username,
'LastUpdated': now,
'Verfuegbar': False,
}}
)
if result.modified_count > 0:
soft_deleted_items += 1
elif result.matched_count == 0:
delete_success = False
borrow_result = db['ausleihungen'].update_many(
{
'Item': {'$in': unique_group_item_ids},
'Status': {'$ne': 'deleted'}
},
{'$set': {
'Status': 'deleted',
'DeletedAt': now,
'DeletedBy': username,
'LastUpdated': now,
}}
)
archive_result = {
'archive_created': False,
'archived_files': 0,
'deleted_files': 0,
'archive_path': None,
}
try:
archive_result = encrypt_soft_deleted_media_pack(item_docs_for_archive, actor=username)
except Exception as archive_err:
app.logger.warning(f"Soft-delete media archive failed: {archive_err}")
_append_audit_event_standalone(
event_type='inventory_item_soft_deleted',
payload={
'root_item_ids': root_item_ids,
'group_item_ids': unique_group_item_ids,
'soft_deleted_items': soft_deleted_items,
'soft_deleted_borrow_records': borrow_result.modified_count,
'soft_delete_archive': archive_result,
}
)
return {
'success': delete_success,
'soft_deleted_items': soft_deleted_items,
'soft_deleted_borrows': borrow_result.modified_count,
'group_item_ids': unique_group_item_ids,
'archive': archive_result,
'message': 'OK' if delete_success else 'Fehler beim revisionssicheren Deaktivieren des Elements.',
}
@app.route('/delete_item/<id>', methods=['POST'])
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'))
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
delete_result = _soft_delete_item_groups(db, [id], session.get('username', ''))
delete_success = bool(delete_result.get('success'))
soft_deleted_items = int(delete_result.get('soft_deleted_items', 0))
soft_deleted_borrows = int(delete_result.get('soft_deleted_borrows', 0))
archived_files = int((delete_result.get('archive') or {}).get('archived_files', 0))
except Exception as e:
app.logger.error(f"Error during soft-delete for item group {id}")
delete_success = False
archived_files = 0
finally:
if client:
client.close()
if delete_success:
flash(
f'Elementgruppe revisionssicher deaktiviert ({soft_deleted_items}/{len(group_item_ids)} Versionen, {archived_files} Mediendateien verschlüsselt archiviert).',
'success'
)
else:
flash('Fehler beim revisionssicheren Deaktivieren des Elements.', 'error')
return redirect(url_for('home_admin'))
@app.route('/delete_library_item/<id>', methods=['POST'])
def delete_library_item(id):
"""
Route to soft-delete library items (books/media) revisionssicher.
Requires admin and library module enabled.
"""
if 'username' not in session:
flash('Nicht angemeldet.', 'error')
return redirect(url_for('login'))
if not us.check_admin(session['username']):
flash('Administratorrechte erforderlich.', 'error')
return redirect(url_for('login'))
if not cfg.MODULES.is_enabled('library'):
flash('Bibliotheks-Modul ist deaktiviert.', 'error')
return redirect(url_for('home_admin'))
# Verify item exists and is a library item
try:
item_doc = it.get_item(id)
except Exception:
item_doc = None
if not item_doc:
flash('Element nicht gefunden.', 'error')
return redirect(url_for('library_admin'))
if item_doc.get('ItemType') not in LIBRARY_ITEM_TYPES:
flash('Element ist kein Bibliotheksmedium.', 'error')
return redirect(url_for('library_admin'))
group_item_ids = it.get_group_item_ids(id)
if not group_item_ids:
flash('Element nicht gefunden.', 'error')
return redirect(url_for('library_admin'))
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
result = _soft_delete_item_groups(db, [id], session.get('username', ''))
success = bool(result.get('success'))
soft_deleted = int(result.get('soft_deleted_items', 0))
archived_files = int((result.get('archive') or {}).get('archived_files', 0))
try:
_append_audit_event_standalone(
event_type='library_item_deleted',
payload={
'root_item_id': id,
'group_item_ids': result.get('group_item_ids', []),
'soft_deleted_items': soft_deleted,
'archived_files': archived_files,
}
)
except Exception:
app.logger.warning('Audit write failed for library_item_deleted')
except Exception as e:
app.logger.error(f"Error deleting library item {id}: {e}")
success = False
archived_files = 0
finally:
if client:
client.close()
if success:
flash(f'Bibliotheksmedium revisionssicher deaktiviert ({soft_deleted} Versionen, {archived_files} Mediendateien archiviert).', 'success')
else:
flash('Fehler beim Löschen des Bibliotheksmediums.', 'error')
return redirect(url_for('library_admin'))
@app.route('/bulk_delete_items', methods=['POST'])
def bulk_delete_items():
"""Soft-delete multiple selected item groups in one request."""
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 {}
item_ids = payload.get('item_ids') or request.form.getlist('item_ids')
if isinstance(item_ids, str):
item_ids = [item_ids]
item_ids = [str(item_id).strip() for item_id in item_ids if str(item_id).strip()]
if not item_ids:
return jsonify({'success': False, 'message': 'Keine Elemente ausgewählt.'}), 400
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
result = _soft_delete_item_groups(db, item_ids, session.get('username', ''))
if not result.get('success'):
return jsonify({
'success': False,
'message': result.get('message', 'Fehler beim Löschen.'),
'deleted_items': result.get('soft_deleted_items', 0),
'deleted_borrows': result.get('soft_deleted_borrows', 0),
}), 400
return jsonify({
'success': True,
'message': f"{result.get('soft_deleted_items', 0)} Elemente revisionssicher deaktiviert.",
'deleted_items': result.get('soft_deleted_items', 0),
'deleted_borrows': result.get('soft_deleted_borrows', 0),
'archived_files': (result.get('archive') or {}).get('archived_files', 0),
'archive_created': (result.get('archive') or {}).get('archive_created', False),
'group_item_ids': result.get('group_item_ids', []),
})
except Exception as e:
app.logger.error(f"Error during bulk delete for items {item_ids}: {e}")
return jsonify({'success': False, 'message': 'Fehler beim Sammellöschen.'}), 500
finally:
if client:
client.close()
@app.route('/edit_item/<id>', methods=['POST'])
def edit_item(id):
"""
Route for editing an existing inventory item.
Args:
id (str): ID of the item to edit
Returns:
flask.Response: Redirect to admin homepage with status message
"""
if 'username' not in session:
flash('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'))
# Expand special "all values" selections for predefined filters.
filter1 = expand_filter_selection(filter1, 1)
filter2 = expand_filter_selection(filter2, 2)
anschaffungs_jahr = sanitize_form_value(request.form.get('anschaffungsjahr'))
anschaffungs_kosten = sanitize_form_value(request.form.get('anschaffungskosten'))
code_4 = sanitize_form_value(request.form.get('code_4'))
isbn_raw = sanitize_form_value(request.form.get('isbn', ''))
reservierbar = 'reservierbar' in request.form
item_isbn = ''
item_type = 'general'
if cfg.MODULES.is_enabled('library'):
item_isbn = normalize_and_validate_isbn(isbn_raw)
if isbn_raw and not item_isbn:
flash('Ungültige ISBN. Bitte ISBN-10 oder ISBN-13 verwenden.', 'error')
return redirect(url_for('home_admin'))
if item_isbn:
item_type = 'book'
# 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)
result = it.update_item(
id=id,
name=name,
ort=ort,
beschreibung=beschreibung,
images=images,
verfuegbar=verfuegbar,
filter1=filter1,
filter2=filter2,
filter3=filter3,
ansch_jahr=anschaffungs_jahr,
ansch_kost=anschaffungs_kosten,
code_4=code_4,
reservierbar=reservierbar,
isbn=item_isbn,
item_type=item_type
)
if result:
flash('Element erfolgreich aktualisiert (und ggf. Gruppe synchronisiert)', 'success')
else:
flash('Fehler beim Aktualisieren des Elements', 'error')
return redirect(url_for('home_admin'))
@app.route('/update_group', methods=['POST'])
def update_group():
data = request.get_json()
series_group_id = data.get('series_group_id')
if not series_group_id:
return jsonify({'success': False, 'message': 'Keine Gruppen-ID'}), 400
# 1. Shared Fields (Group Logic)
# These apply to every item in the group
shared_update = {
'Name': data.get('name'),
'Ort': data.get('ort'),
'Beschreibung': data.get('beschreibung'),
'Anschaffungsjahr': data.get('ansch_jahr'),
'Anschaffungskosten': data.get('ansch_kost'),
'Reservierbar': data.get('reservierbar'),
'ISBN': data.get('isbn'),
'ItemType': data.get('item_type'),
'LastUpdated': datetime.datetime.now()
}
# 2. Individual Updates (Specific Code Logic)
# Expected format: [{'id': '...', 'code_4': '...'}, ...]
individual_items = data.get('items', [])
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items_col = db['items']
# A. Apply Shared Attributes to the whole group
items_col.update_many(
{'SeriesGroupId': series_group_id},
{'$set': shared_update}
)
# B. Apply Unique Codes to specific items
# We iterate through the provided list to update the specific code for each ID
for item in individual_items:
item_id = item.get('id')
new_code = item.get('code_4')
if item_id:
items_col.update_one(
{'_id': ObjectId(item_id), 'SeriesGroupId': series_group_id},
{'$set': {'Code_4': new_code}}
)
client.close()
return jsonify({'success': True, 'message': 'Gruppe und individuelle Codes aktualisiert'})
except Exception as e:
app.logger.error(f"Error updating group {series_group_id}: {e}")
return jsonify({'success': False, 'message': 'Systemfehler beim Update'}), 500
@app.route('/report_damage/<id>', 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
_create_notification(
db,
audience='admin',
notif_type='damage_reported',
title='Defekt gemeldet',
message=(
f"Fuer das Item '{item_doc.get('Name', id)}' wurde ein Defekt gemeldet. "
f"Meldung von {session.get('username', '-')}: {description}"
),
reference={'item_id': id, 'damage_count': damage_count, 'url': '/admin/damaged_items'},
severity='danger',
)
# 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}")
try:
_append_audit_event_standalone(
event_type='item_damage_reported',
payload={
'item_id': id,
'item_name': item_doc.get('Name', ''),
'reported_by': session.get('username'),
'description': description,
'damage_count': damage_count,
}
)
except Exception:
app.logger.warning('Audit write failed for item_damage_reported')
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/<id>', 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}")
try:
_append_audit_event_standalone(
event_type='item_damage_repaired',
payload={
'item_id': id,
'resolved_by': session.get('username'),
'resolved_count': len(open_reports),
}
)
except Exception:
app.logger.warning('Audit write failed for item_damage_repaired')
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/<id>', 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.MODULES.is_enabled('library'):
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.MODULES.is_enabled('student_cards'):
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.MODULES.is_enabled('student_cards'):
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)
_append_audit_event_standalone(
event_type='ausleihung_returned',
payload={
'channel': 'inventory_route',
'item_id': id,
'borrower': effective_borrower,
'borrow_duration_days': borrow_duration_days,
'selected_unit_ids': [str(unit.get('_id')) for unit in selected_units],
'selected_unit_codes': [str(unit.get('Code_4') or '') for unit in selected_units],
}
)
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:
app.logger.warning(f"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)
_append_audit_event_standalone(
event_type='ausleihung_returned',
payload={
'channel': 'inventory_route',
'item_id': id,
'borrower': effective_borrower,
'borrow_duration_days': borrow_duration_days,
'single_item': True,
}
)
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']
})
_append_audit_event_standalone(
event_type='ausleihung_returned',
payload={
'channel': 'inventory_route',
'item_id': id,
'borrower': effective_borrower,
'borrow_duration_days': borrow_duration_days,
'exemplar_numbers': [ex.get('number') for ex in new_borrowed_exemplars],
}
)
flash(f'{exemplare_count} Exemplare erfolgreich ausgeliehen', 'success')
return redirect(url_for(redirect_target))
@app.route('/zurueckgeben/<id>', 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']
if not item.get('Verfuegbar', True) and (us.check_admin(session['username']) or item.get('User') == username):
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'])
# 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')
_append_audit_event_standalone(
event_type='ausleihung_returned',
payload={
'channel': 'inventory_route',
'item_id': id,
'returned_by': username,
'original_borrower': original_user,
'completed_records': updated_count,
}
)
except Exception as e:
app.logger.error(f"Error in return process: {e}")
it.update_item_status(id, True)
flash('Element zurückgegeben, aber ein Fehler ist aufgetreten', '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 by filter field
"""
return jsonify({
'filter1': it.get_primary_filters(),
'filter2': it.get_secondary_filters(),
'filter3': it.get_tertiary_filters(),
})
@app.route('/get_ausleihung_by_item/<id>')
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/<item_id>')
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}), 500
@app.route('/get_planned_bookings_public/<item_id>')
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}), 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}), 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 = (request.form.get('item_id') or '').strip()
start_date_str = (request.form.get('booking_date') or request.form.get('start_date') or '').strip()
end_date_str = (request.form.get('booking_end_date') or request.form.get('end_date') or '').strip()
period_start = (request.form.get('period_start') or '').strip()
period_end = (request.form.get('period_end') or '').strip()
notes = html.escape(request.form.get('notes', '') or '')
booking_type = (request.form.get('booking_type', 'single') or 'single').strip().lower()
# Validate inputs
if not all([item_id, start_date_str, period_start]):
return {"success": False, "error": "Missing required fields"}, 400
if not end_date_str:
end_date_str = start_date_str
# 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:
app.logger.error(f"Invalid date format: {e}")
return {"success": False, "error": "Invalid date format"}, 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}, 500
else:
# All succeeded
return {"success": True, "booking_ids": booking_ids}
except Exception as e:
import traceback
app.logger.error(f"Error in plan_booking: {e}")
return {"success": False, "error": f"Fehler beim Planen der Buchung"}, 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})
@app.route('/cancel_booking/<id>', 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'))
if not cfg.MODULES.is_enabled('terminplan'):
flash('Der Terminplaner ist deaktiviert.', 'info')
return redirect(url_for('home'))
# 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:
app.logger.error(f"Error rendering terminplan: {e}")
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 'username' in session:
if request.method == 'POST':
password = request.form['password']
name = (request.form.get('name') or '').strip()
last_name = (request.form.get('last-name') or '').strip()
# Generate a username from the first 2 letters of first and last name.
username = us.build_unique_username_from_name(name, last_name)
permission_preset = (request.form.get('permission_preset') or 'standard_user').strip()
use_custom_permissions = request.form.get('use_custom_permissions') == 'on'
if not username or not password or not name or not last_name:
flash('Bitte füllen Sie alle Felder aus', 'error')
return redirect(url_for('register'))
if not us.check_password_strength(password):
flash('Passwort ist zu schwach', 'error')
return redirect(url_for('register'))
action_permissions = None
page_permissions = None
if use_custom_permissions:
action_permissions = {}
for action_key, _ in PERMISSION_ACTION_OPTIONS:
action_permissions[action_key] = request.form.get(f'action_{action_key}') == 'on'
page_permissions = {}
for endpoint_name, _ in PERMISSION_PAGE_OPTIONS:
page_permissions[endpoint_name] = request.form.get(f'page_{endpoint_name}') == 'on'
us.add_user(
username,
password,
name,
last_name,
is_student=False,
student_card_id=None,
max_borrow_days=None,
permission_preset=permission_preset,
action_permissions=action_permissions,
page_permissions=page_permissions,
)
return redirect(url_for('home'))
return render_template(
'register.html',
library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
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'))
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:
permissions_payload = us.get_effective_permissions(username)
except Exception:
permissions_payload = us.build_default_permission_payload('standard_user')
try:
name = us.get_name(username)
last_name = us.get_last_name(username)
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,
'permission_preset': permissions_payload.get('preset', 'standard_user'),
'action_permissions': permissions_payload.get('actions', {}),
'page_permissions': permissions_payload.get('pages', {}),
})
return render_template(
'user_del.html',
users=users_list,
library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards')
)
@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'))
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:
app.logger.error(f"Error resetting borrowings for user {encrypt_text(username)}: {e}")
flash(f'Warnung: Ausleihungen/Reservierungen für {username} konnten nicht vollständig zurückgesetzt werden', 'warning')
# Delete the user
try:
us.delete_user(username)
flash(f'Benutzer {username} erfolgreich gelöscht', 'success')
except Exception as e:
app.logger.error(f"Error deleting user {encrypt_text(username)}: {e}")
flash('Fehler beim Löschen des Benutzers', 'error')
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'))
_ensure_audit_indexes_once()
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 '',
'invoice_corrections_count': len(r.get('InvoiceCorrections', []) or []),
'has_damage': has_damage,
})
client.close()
return render_template(
'admin_borrowings.html',
entries=entries,
library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
mail_module_enabled=cfg.MODULES.is_enabled('mail')
)
"""-----------------------------------------------------------Audit Routes-------------------------------------------------------"""
@app.route('/admin/audit/verify', methods=['GET'])
def admin_verify_audit_chain():
"""Admin endpoint to verify audit chain integrity."""
if 'username' not in session or not us.check_admin(session['username']):
return jsonify({'ok': False, 'error': 'forbidden'}), 403
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
al.ensure_audit_indexes(db)
result = al.verify_audit_chain(db)
status_code = 200 if result.get('ok') else 409
return jsonify(result), status_code
except Exception as exc:
return jsonify({'ok': False, 'error': 'Internal server error'}), 500
finally:
if client:
client.close()
@app.route('/admin/audit', methods=['GET'])
def admin_audit_dashboard():
"""Admin dashboard for audit chain status and recent events."""
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]
al.ensure_audit_indexes(db)
verify_result = al.verify_audit_chain(db)
audit_rows = list(db['audit_log'].find({}).sort('chain_index', -1).limit(200))
# DEC_START: Decrypt the sensitive fields for display
for row in audit_rows:
if "payload" in row:
# decrypt_document_fields acts in-place
decrypt_document_fields(row["payload"], SENSITIVE_AUDIT_FIELDS)
# DEC_END
return render_template(
'admin_audit.html',
verify_result=verify_result,
audit_rows=audit_rows,
library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
mail_module_enabled=cfg.MODULES.is_enabled('mail'),
)
except Exception as exc:
app.logger.error(f"Error loading audit dashboard: {exc}")
flash('Fehler beim Laden des Audit-Dashboards.', 'error')
return redirect(url_for('home_admin'))
finally:
if client:
client.close()
@app.route('/admin/audit/export/pdf/official', methods=['GET'])
def admin_audit_export_pdf_official():
"""Export audit report as professional PDF (Official Report - full DIN 5008 compliant)."""
if 'username' not in session or not us.check_admin(session['username']):
return jsonify({'ok': False, 'error': 'forbidden'}), 403
try:
limit = int((request.args.get('limit') or '1000').strip())
except Exception:
limit = 1000
limit = max(1, min(limit, 5000))
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
al.ensure_audit_indexes(db)
verify_result = al.verify_audit_chain(db)
event_counts = list(
db['audit_log'].aggregate([
{'$group': {'_id': '$event_type', 'count': {'$sum': 1}}},
{'$project': {'_id': 0, 'event_type': {'$ifNull': ['$_id', 'unknown']}, 'count': 1}},
{'$sort': {'count': -1, 'event_type': 1}}
])
)
audit_rows = list(db['audit_log'].find({}).sort('chain_index', -1).limit(limit))
# DEC_START: Decrypt sensitive fields for the PDF report
for row in audit_rows:
if "payload" in row:
decrypt_document_fields(row["payload"], SENSITIVE_AUDIT_FIELDS)
# DEC_END
# Get school information from settings or use defaults
school_info = _get_school_info_for_export()
# Generate PDF with now-decrypted audit_rows
pdf_content = pdf_export.generate_audit_pdf(
verify_result=verify_result,
event_counts=event_counts,
audit_rows=audit_rows,
export_type='official',
school_info=school_info
)
response = make_response(pdf_content)
response.headers['Content-Type'] = 'application/pdf'
response.headers['Content-Disposition'] = f'attachment; filename=audit-official-report-{datetime.datetime.utcnow().strftime("%Y%m%d-%H%M%S")}.pdf'
return response
except Exception as exc:
app.logger.error(f"PDF Official Report export error: {str(exc)}\n{traceback.format_exc()}")
return jsonify({'ok': False, 'error': 'Internal server error'}), 500
finally:
if client:
client.close()
"""-----------------------------------------------------------Image Cache Management Routes-------------------------------------------------------"""
@app.route('/admin/image_cache_stats', methods=['GET'])
def admin_image_cache_stats():
"""
Get statistics about optimized image cache.
Admin-only endpoint for monitoring and maintenance.
Returns:
JSON with cache statistics (file count, total size, creation dates)
"""
if 'username' not in session:
return jsonify({'ok': False, 'error': 'unauthorized'}), 401
permissions = _get_current_user_permissions()
if not _action_access_allowed(permissions, 'can_manage_settings'):
return jsonify({'ok': False, 'error': 'forbidden'}), 403
try:
cache_dir = os.path.join(app.config['THUMBNAIL_FOLDER'], 'optimized_480p')
if not os.path.exists(cache_dir):
return jsonify({
'ok': True,
'cache_exists': False,
'file_count': 0,
'total_size_mb': 0
})
files = []
total_size = 0
for filename in os.listdir(cache_dir):
file_path = os.path.join(cache_dir, filename)
if not os.path.isfile(file_path):
continue
file_size = os.path.getsize(file_path)
total_size += file_size
mod_time = os.path.getmtime(file_path)
files.append({
'name': filename,
'size_kb': round(file_size / 1024, 2),
'modified': datetime.datetime.fromtimestamp(mod_time).isoformat()
})
files.sort(key=lambda x: x['modified'], reverse=True)
return jsonify({
'ok': True,
'cache_exists': True,
'file_count': len(files),
'total_size_mb': round(total_size / (1024 * 1024), 2),
'files': files[:20] # Return only newest 20 files
})
except Exception as e:
app.logger.error(f"Error getting cache stats: {str(e)}")
return jsonify({'ok': False}), 500
@app.route('/admin/image_cache_cleanup', methods=['POST'])
def admin_image_cache_cleanup():
"""
Trigger cleanup of old optimized images.
Admin-only endpoint for maintenance.
Args (via form):
max_age_days: Delete images older than this many days (default 30)
Returns:
JSON with cleanup results (deleted count, freed space)
"""
if 'username' not in session:
return jsonify({'ok': False, 'error': 'unauthorized'}), 401
permissions = _get_current_user_permissions()
if not _action_access_allowed(permissions, 'can_manage_settings'):
return jsonify({'ok': False, 'error': 'forbidden'}), 403
try:
max_age_days = int(request.form.get('max_age_days', 30))
max_age_days = max(1, min(max_age_days, 365)) # Clamp between 1 and 365 days
result = cleanup_old_optimized_images(max_age_days)
if result['error']:
return jsonify({'ok': False, 'error': result['error']}), 500
# Log the action
_append_audit_event_standalone(
event_type='admin_image_cache_cleanup',
payload={
'max_age_days': max_age_days,
'deleted_count': result['deleted'],
'freed_mb': result['freed_mb']
}
)
return jsonify({
'ok': True,
'deleted': result['deleted'],
'freed_mb': result['freed_mb'],
'message': f"Cleaned up {result['deleted']} images, freed {result['freed_mb']} MB"
})
except Exception as e:
app.logger.error(f"Error during image cache cleanup: {str(e)}")
return jsonify({'ok': False}), 500
"""-----------------------------------------------------------Borrowing Management Routes-------------------------------------------------------"""
@app.route('/admin/reset_borrowing/<borrow_id>', 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')
_append_audit_event_standalone(
event_type='ausleihung_admin_reset',
payload={
'borrow_id': borrow_id,
'from_status': 'active',
'to_status': 'completed',
'item_id': str(item_id or ''),
'borrower': str(user or ''),
}
)
elif status == 'planned':
ausleihungen.update_one({'_id': rec['_id']}, {'$set': {'Status': 'cancelled', 'LastUpdated': now}})
flash('Geplante Ausleihe wurde storniert.', 'success')
_append_audit_event_standalone(
event_type='ausleihung_admin_reset',
payload={
'borrow_id': borrow_id,
'from_status': 'planned',
'to_status': 'cancelled',
'item_id': str(item_id or ''),
'borrower': str(user or ''),
}
)
else:
flash('Diese Ausleihe ist weder aktiv noch geplant.', 'warning')
client.close()
except Exception as e:
app.logger.error(f"Error resetting borrowing status for {borrow_id}: {e}")
flash('Fehler beim Zurücksetzen', 'error')
return redirect(url_for('admin_borrowings'))
@app.route('/admin/borrowings/<borrow_id>/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 {}
if existing_invoice.get('invoice_number'):
flash('Für diese Ausleihe existiert bereits eine Rechnung. Bitte Korrekturbuchung verwenden.', 'warning')
return redirect(url_for('admin_borrowings'))
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(borrow_id)})
card = _decrypt_student_card_doc(card)
client.close()
borrower = card['SchülerName']
invoice_number = existing_invoice.get('invoice_number') or _build_invoice_number(borrow_doc['_id'], now)
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,
'InvoiceLocked': True,
'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}")
_append_audit_event_standalone(
event_type='invoice_created',
payload={
'borrow_id': borrow_id,
'invoice_number': invoice_number,
'amount': round(amount_value, 2),
'mark_destroyed': mark_destroyed,
'close_borrowing': close_borrowing,
'item_id': str(item_doc.get('_id')),
}
)
pdf_buffer = pdf_export._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/<borrow_id>/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}")
_append_audit_event_standalone(
event_type='invoice_marked_paid',
payload={
'borrow_id': borrow_id,
'invoice_number': invoice_data.get('invoice_number', ''),
'amount': invoice_data.get('amount'),
}
)
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/<borrow_id>/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}")
_append_audit_event_standalone(
event_type='invoice_finalized_and_repaired',
payload={
'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,
}
)
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/<borrow_id>/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 = pdf_export._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/borrowings/<borrow_id>/invoice/correction', methods=['POST'])
def admin_add_invoice_correction(borrow_id):
"""Append an invoice correction entry without mutating the original invoice body."""
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('Korrektur nicht möglich: Es existiert noch keine Rechnung.', 'warning')
return redirect(url_for('admin_borrowings'))
correction_reason = str(request.form.get('correction_reason', '')).strip()
if not correction_reason:
flash('Bitte eine Begründung für die Korrektur angeben.', 'error')
return redirect(url_for('admin_borrowings'))
delta_raw = request.form.get('amount_delta')
delta_value = _parse_money_value(delta_raw) if delta_raw not in (None, '') else 0.0
if delta_value is None:
flash('Ungültiger Korrekturbetrag.', 'error')
return redirect(url_for('admin_borrowings'))
now = datetime.datetime.now()
correction_number = f"CORR-{now.strftime('%Y%m%d-%H%M%S')}-{str(borrow_doc.get('_id'))[-6:].upper()}"
correction_entry = {
'correction_number': correction_number,
'reason': correction_reason,
'amount_delta': round(delta_value, 2),
'amount_delta_text': _format_money_value(delta_value),
'created_at': now,
'created_by': session.get('username', ''),
'invoice_number': invoice_data.get('invoice_number', ''),
}
ausleihungen.update_one(
{'_id': borrow_doc['_id']},
{
'$push': {'InvoiceCorrections': {'$each': [correction_entry], '$position': 0}},
'$set': {'LastUpdated': now, 'InvoiceLocked': True}
}
)
_append_audit_event_standalone(
event_type='invoice_correction_added',
payload={
'borrow_id': borrow_id,
'invoice_number': invoice_data.get('invoice_number', ''),
'correction_number': correction_number,
'amount_delta': round(delta_value, 2),
'reason': correction_reason,
}
)
flash('Korrekturbuchung wurde revisionssicher ergänzt.', 'success')
return redirect(url_for('admin_borrowings'))
except Exception as e:
app.logger.error(f"Error creating invoice correction for borrow {borrow_id}: {e}")
flash('Fehler beim Anlegen der Korrekturbuchung.', 'error')
return redirect(url_for('admin_borrowings'))
finally:
if client:
client.close()
@app.route('/admin/library/items/<item_id>/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.MODULES.is_enabled('library'):
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.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
mail_module_enabled=cfg.MODULES.is_enabled('mail')
)
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'))
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 {encrypt_text(username)} wurde erfolgreich zurückgesetzt auf: {new_password}', 'success')
except Exception as e:
app.logger.error(f'Error resetting password for {encrypt_text(username)}: {e}')
flash('Fehler beim Zurücksetzen des Passworts', '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('/admin_update_user_permissions', methods=['POST'])
def admin_update_user_permissions():
"""Admin route to update permission preset and per-endpoint overrides for a user."""
if 'username' not in session:
flash('Nicht autorisierter Zugriff', 'error')
return redirect(url_for('login'))
username = request.form.get('username', '').strip()
preset_key = request.form.get('permission_preset', 'standard_user').strip()
if not username:
flash('Kein Benutzer ausgewählt', 'error')
return redirect(url_for('user_del'))
target_user = us.get_user(username)
if not target_user:
flash(f'Benutzer {username} nicht gefunden', 'error')
return redirect(url_for('user_del'))
action_permissions = {}
for action_key, _ in PERMISSION_ACTION_OPTIONS:
action_permissions[action_key] = request.form.get(f'action_{action_key}') == 'on'
page_permissions = {}
for endpoint_name, _ in PERMISSION_PAGE_OPTIONS:
page_permissions[endpoint_name] = request.form.get(f'page_{endpoint_name}') == 'on'
if us.update_user_permissions(username, preset_key, action_permissions, page_permissions):
flash(f'Berechtigungen für {username} wurden aktualisiert.', 'success')
else:
flash('Fehler beim Aktualisieren der Berechtigungen.', 'error')
return redirect(url_for('user_del'))
@app.route('/admin_anonymize_names', methods=['POST'])
def admin_anonymize_names():
"""Anonymize already stored personal names into short aliases."""
if 'username' not in session:
flash('Nicht autorisierter Zugriff', 'error')
return redirect(url_for('login'))
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
users_col = db['users']
student_cards_col = db['student_cards']
users_updated = 0
cards_updated = 0
for user_doc in users_col.find({}, {'name': 1, 'last_name': 1, 'Username': 1, 'username': 1}):
first = str(user_doc.get('name') or '').strip()
last = str(user_doc.get('last_name') or '').strip()
fallback = str(user_doc.get('Username') or user_doc.get('username') or '').strip()
alias = us.build_name_synonym(first or fallback, last)
result = users_col.update_one(
{'_id': user_doc['_id']},
{'$set': {'name': alias, 'last_name': ''}}
)
if result.modified_count > 0:
users_updated += 1
for card_doc in student_cards_col.find({}, {'SchülerName': 1, 'Klasse': 1, 'Notizen': 1}):
decrypted = _decrypt_student_card_doc(card_doc)
class_name = sanitize_form_value(decrypted.get('Klasse', ''))
notes = sanitize_form_value(decrypted.get('Notizen', ''))
encrypted_payload = encrypt_document_fields(
{
'SchülerName': decrypted.get('SchülerName', ''),
'Klasse': class_name,
'Notizen': notes,
},
STUDENT_CARD_ENCRYPTED_FIELDS,
)
result = student_cards_col.update_one(
{'_id': card_doc['_id']},
{'$set': {'Aktualisiert': datetime.datetime.now(), **encrypted_payload}}
)
if result.modified_count > 0:
cards_updated += 1
flash(
f'Anonymisierung abgeschlossen: {users_updated} Benutzer und {cards_updated} Ausweise aktualisiert.',
'success'
)
except Exception as exc:
app.logger.error(f'Error anonymizing names: {exc}')
flash('Fehler bei der Anonymisierung der Namen.', 'error')
finally:
if client:
client.close()
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.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards')
)
@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)
filter3_values = it.get_predefined_filter_values(3)
# Get custom filter names
filter_names = it.get_filter_names()
return render_template(
'manage_filters.html',
filter1_values=filter1_values,
filter2_values=filter2_values,
filter3_values=filter3_values,
filter_names=filter_names,
library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards')
)
@app.route('/add_filter_value/<int:filter_num>', 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'))
if value == FILTER_SELECT_ALL_TOKEN:
flash('Dieser Wert ist reserviert und kann nicht als Filterwert gespeichert werden.', '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/<int:filter_num>/<string: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('/edit_filter_value/<int:filter_num>/<string:old_value>', methods=['POST'])
def edit_filter_value(filter_num, old_value):
"""
Edit a predefined value from the specified filter.
Args:
filter_num (int): Filter number (1, 2 or 3)
old_value (str): Value to edit
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
new_value = sanitize_form_value(request.form.get('new_value'))
if not new_value:
flash('Bitte geben Sie einen neuen Wert ein', 'error')
return redirect(url_for('manage_filters'))
if new_value == FILTER_SELECT_ALL_TOKEN:
flash('Dieser Wert ist reserviert und kann nicht als Filterwert gespeichert werden.', 'error')
return redirect(url_for('manage_filters'))
# Update the value from the filter
success = it.edit_predefined_filter_value(filter_num, old_value, new_value)
if success:
flash(f'Wert "{old_value}" wurde in "{new_value}" bei Filter {filter_num} geändert', 'success')
else:
flash(f'Fehler beim Ändern des Wertes oder Wert existiert bereits in Filter {filter_num}', 'error')
return redirect(url_for('manage_filters'))
@app.route('/get_predefined_filter_values/<int:filter_num>')
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/<path: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:
app.logger.error(f"Error searching for word: {e}")
return jsonify({"success": False})
def _fetch_from_google_books(clean_isbn):
"""Source 1: Google Books API (Free, No Key required for basic use)"""
try:
url = f"https://www.googleapis.com/books/v1/volumes?q=isbn:{clean_isbn}"
response = requests.get(url, timeout=5)
if response.status_code != 200:
return None
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 {
"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', 'Keine Beschreibung verfügbar'),
"pageCount": book_info.get('pageCount', 'Unknown'),
"price": price,
"thumbnail": thumbnail,
"source": "google-books"
}
except Exception as e:
print(f"Google Books error: {e}")
return None
def _fetch_from_lobid_germany(clean_isbn):
"""
Source 2: Lobid.org (hbz Network)
The ultimate free, keyless fallback for German Schoolbooks and DACH literature.
"""
try:
url = f"https://lobid.org/resources?q=isbn:{clean_isbn}&format=json"
response = requests.get(url, timeout=5)
if response.status_code != 200:
return None
data = response.json()
if 'member' in data and len(data['member']) > 0:
book = data['member'][0]
# Extract Authors (Lobid uses a nested contribution array)
authors = []
for contrib in book.get('contribution', []):
if 'agent' in contrib and 'label' in contrib['agent']:
authors.append(contrib['agent']['label'])
# Extract Publishers
publishers = []
for pub in book.get('publication', []):
if 'publishedBy' in pub:
# sometimes publishedBy is a list, sometimes a string
pub_data = pub['publishedBy']
if isinstance(pub_data, list):
publishers.extend(pub_data)
else:
publishers.append(pub_data)
# Extract Date
pub_date = 'Unknown Date'
if book.get('publication'):
pub_date = book['publication'][0].get('startDate', 'Unknown Date')
# Extract Pages
pages = book.get('extent', ['Unknown'])[0] if 'extent' in book else 'Unknown'
return {
"title": book.get('title', 'Unknown Title'),
"authors": ', '.join(authors) if authors else 'Unknown Author',
"publisher": ', '.join(publishers) if publishers else 'Unknown Publisher',
"publishedDate": str(pub_date),
"description": 'Keine Beschreibung verfügbar (Schulbuch / Fachliteratur)',
"pageCount": pages,
"price": None,
"thumbnail": "", # Lobid rarely has covers, we will fallback to OpenLibrary cover below
"source": "lobid-germany"
}
except Exception as e:
print(f"Lobid error: {e}")
return None
def _fetch_from_open_library(clean_isbn):
"""Source 3: Open Library Search API (Free, No Key required)"""
try:
url = f"https://openlibrary.org/search.json?isbn={clean_isbn}"
response = requests.get(url, timeout=5)
if response.status_code != 200:
return None
data = response.json()
if data.get('numFound', 0) > 0 and data.get('docs'):
book_info = data['docs'][0]
return {
"title": book_info.get('title', 'Unknown Title'),
"authors": ', '.join(book_info.get('author_name', ['Unknown Author'])),
"publisher": ', '.join(book_info.get('publisher', ['Unknown Publisher'])),
"publishedDate": str(book_info.get('publish_date', ['Unknown Date'])[0]),
"description": 'Keine Beschreibung verfügbar',
"pageCount": book_info.get('number_of_pages_median', 'Unknown'),
"price": None,
"thumbnail": f"https://covers.openlibrary.org/b/isbn/{clean_isbn}-L.jpg",
"source": "openlibrary"
}
except Exception as e:
print(f"OpenLibrary Search error: {e}")
return None
def _fetch_from_isbn_de(clean_isbn):
"""
Source 4: isbn.de (Maßgeschneidertes Scraping basierend auf realem HTML)
Extrahiert alle Buchdaten inklusive Preise aus den Meta-Tags und der Sidebar.
"""
try:
url = f"https://www.isbn.de/buch/{clean_isbn}"
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
response = requests.get(url, headers=headers, timeout=5)
if response.status_code != 200:
return None
soup = BeautifulSoup(response.text, 'html.parser')
# 1. Titel aus Meta-Tags extrahieren (extrem zuverlässig)
title_meta = soup.find('meta', property='og:title')
title = title_meta.get('content', '').strip() if title_meta else None
if not title:
h1_elem = soup.find('h1')
title = h1_elem.text.strip() if h1_elem else "Unknown Title"
# Falls Fehler- oder Suchseite
if "nicht gefunden" in title.lower() or "suche" in title.lower():
return None
# --- Hilfsfunktion zum Parsen der .infotab-Sidebar-Struktur ---
def get_sidebar_value(keyword):
infotab = soup.find(class_='infotab')
if infotab:
for d in infotab.find_all('div'):
if d.text.strip().lower() == keyword.lower():
parent = d.parent
# Label vom Gesamttext abziehen, um nur den Wert zu erhalten
return parent.text.replace(d.text, '', 1).strip()
return None
# 2. Verlag holen
publisher = get_sidebar_value('verlag')
if not publisher:
publisher = "Unknown Publisher"
# 3. Erscheinungsdatum (Aus Meta-Tag oder Sidebar)
date_meta = soup.find('meta', property='og:book:release_date')
if date_meta and date_meta.get('content'):
published_date = date_meta.get('content', '').strip()
else:
published_date = get_sidebar_value('erschienen am')
if not published_date:
published_date = "Unknown Date"
# 4. Autor (Da Schulbuch-Workbooks oft keinen Einzelautor haben, kluger Fallback)
author_meta = soup.find('meta', property='og:book:author')
author = author_meta.get('content', '').strip() if author_meta else ""
if not author:
author = get_sidebar_value('autor') or get_sidebar_value('herausgeber')
if not author:
author = f"{publisher} Redaktion" if publisher != "Unknown Publisher" else "Unknown Author"
# 5. Seitenanzahl
page_count = get_sidebar_value('seiten') or get_sidebar_value('umfang')
if page_count:
match = re.search(r'\d+', page_count)
page_count = match.group(0) if match else "Unknown"
else:
page_count = "Unknown"
# 6. Beschreibung aus ID 'bookdesc' holen und Whitespace-Fluss säubern
description = "Keine Beschreibung verfügbar"
desc_div = soup.find(id='bookdesc')
if desc_div:
# Holt den Text inklusive aller Listenpunkte (li) und formatiert ihn sauber
description = " ".join(desc_div.text.split())
# 7. Cover-Bild (Nutzt hochauflösenden Link aus den Metas)
thumbnail = ""
img_meta = soup.find('meta', property='og:image')
if img_meta:
thumbnail = img_meta.get('content', '').strip()
else:
img_tag = soup.find('img', id='ISBNcover')
if img_tag:
thumbnail = img_tag.get('data-big') or img_tag.get('src')
if thumbnail and thumbnail.startswith('/'):
thumbnail = "https://www.isbn.de" + thumbnail
# 8. PREIS EXTRAHIEREN (Neu integriert)
price = None
# Option A: Aus dem standardisierten Meta-Tag extrahieren (Ergibt z.B. 12.25)
price_meta = soup.find('meta', property='product:price:amount')
if price_meta and price_meta.get('content'):
try:
price = float(price_meta.get('content').strip())
except ValueError:
pass
# Option B: Fallback über die Infotab-Sidebar (Falls Meta fehlt, filtert "12,25 €*" zu Float)
if price is None:
price_str = get_sidebar_value('preis')
if price_str:
# Findet Zahlen wie "12,25" oder "12.25"
match = re.search(r'\d+([.,]\d+)?', price_str)
if match:
try:
price = float(match.group(0).replace(',', '.'))
except ValueError:
pass
return {
"title": title,
"authors": author,
"publisher": publisher,
"publishedDate": published_date,
"description": description,
"pageCount": page_count,
"price": price, # Gibt nun z.B. 12.25 als float zurück
"thumbnail": thumbnail,
"source": "isbn-de"
}
except Exception as e:
print(f"isbn.de Scraping error: {e}")
return None
@app.route('/fetch_book_info/<isbn>')
def fetch_book_info(isbn):
"""
API endpoint to fetch book information by ISBN using multiple open sources.
Optimized for global literature AND German educational books.
"""
# Authorization Checks
if 'username' not in session or not us.check_admin(session['username']):
return jsonify({"error": "Not authorized"}), 403
if not cfg.MODULES.is_enabled('library'):
return jsonify({"error": "Bibliotheks-Modul ist deaktiviert."}), 403
try:
# Validation
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
# Define the Provider Chain (Order matters: General -> German/School -> Global Fallback)
providers = [
_fetch_from_google_books,
_fetch_from_lobid_germany,
_fetch_from_isbn_de,
_fetch_from_open_library
]
# Iterate through providers until a book is found
for provider in providers:
book_data = provider(clean_isbn)
if book_data:
# Add the ISBN back into the response payload
book_data["isbn"] = clean_isbn
# If a provider found the book but had no cover, assign a default OpenLibrary cover fallback
if not book_data.get("thumbnail"):
book_data["thumbnail"] = f"https://covers.openlibrary.org/b/isbn/{clean_isbn}-L.jpg"
return jsonify(book_data)
# If all providers fail
return jsonify({"error": f"Kein Buch zu dieser ISBN gefunden: {clean_isbn}"}), 404
except Exception as e:
app.logger.error(f"Error fetching book data: {e}")
return jsonify({"error": f"Failed to fetch book information"}), 500
@app.route('/download_book_cover', methods=['POST'])
def download_book_cover():
"""
API endpoint to download and save a book cover image from URL
"""
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.MODULES.is_enabled('library'):
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
parsed_url = urlparse(image_url)
if parsed_url.scheme != 'https' or not parsed_url.netloc:
return jsonify({"error": "Only public HTTPS URLs are allowed"}), 400
# 2. SSRF Protection: Strict Allowlist Check
if parsed_url.netloc not in ALLOWED_COVER_DOMAINS:
return jsonify({"error": "Target host is not an allowed book cover provider"}), 403
# Download the image (allow_redirects=False prevents redirecting to internal IPs)
response = requests.get(image_url, stream=True, timeout=10, allow_redirects=False)
if response.status_code != 200:
return jsonify({"error": f"Failed to download image: Status {response.status_code}"}), 400
# Check content type
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
# Check content length header
content_length = response.headers.get('Content-Length')
if content_length:
try:
if int(content_length) > 5 * 1024 * 1024:
return jsonify({"error": "Image is too large"}), 413
except ValueError:
pass
# Generate a fully unique filename
unique_id = str(uuid.uuid4())
timestamp = time.strftime("%Y%m%d%H%M%S")
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}"
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
# Save image in chunks (prevents memory exhaustion and enforces size limits)
with open(filepath, 'wb') as f:
written = 0
for chunk in response.iter_content(chunk_size=8192):
written += len(chunk)
if written > 5 * 1024 * 1024:
# Clean up the partial file before aborting
os.remove(filepath)
return jsonify({"error": "Image is too large"}), 413
f.write(chunk)
return jsonify({
"success": True,
"filename": filename,
"message": "Image downloaded successfully"
})
except requests.exceptions.RequestException as e:
app.logger.error(f"Network error downloading book cover: {e}")
return jsonify({"error": "Netzwerkfehler beim Herunterladen des Bildes."}), 500
except Exception as e:
app.logger.error(f"Error downloading book cover: {e}")
# Fixed syntax here: Removed the injected HTML that was appended to this line
return jsonify({"error": f"Failed to download image"}), 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
parsed_url = urlparse(url)
if parsed_url.scheme != 'https' or not parsed_url.netloc:
return jsonify({"error": "Only public HTTPS URLs are allowed"}), 400
hostname = parsed_url.hostname or ''
if not _is_public_host(hostname):
return jsonify({"error": "Target host is not allowed"}), 400
try:
# Fetch the image from the external source
response = requests.get(url, stream=True, timeout=5, allow_redirects=False)
# 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')
if not content_type.lower().startswith('image/'):
return jsonify({"error": "Target URL did not return an image"}), 400
content_length = response.headers.get('Content-Length')
if content_length:
try:
if int(content_length) > 5 * 1024 * 1024:
return jsonify({"error": "Image is too large"}), 413
except ValueError:
pass
payload = bytearray()
for chunk in response.iter_content(chunk_size=8192):
if not chunk:
continue
payload.extend(chunk)
if len(payload) > 5 * 1024 * 1024:
return jsonify({"error": "Image is too large"}), 413
# Return the image data with appropriate headers
return Response(
response=bytes(payload),
status=200,
headers={
'Content-Type': content_type
}
)
except Exception as e:
app.logger.error(f"Error in proxy_image: {e}")
return jsonify({"error": f"Error fetching image"}), 500
"""
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
"""---------------------------------------------------------Borrowing-----------------------------------------------------------------"""
@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'
}))
# 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('/notifications')
def notifications_view():
"""Notification center for users and admins."""
if 'username' not in session:
flash('Bitte melden Sie sich an, um Benachrichtigungen zu sehen.', 'error')
return redirect(url_for('login'))
username = session['username']
is_admin_user = False
try:
is_admin_user = us.check_admin(username)
except Exception:
is_admin_user = False
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
notifications = _get_notifications_for_user(db, username, is_admin=is_admin_user, limit=200)
user_notifications = []
admin_notifications = []
for notif in notifications:
is_read = username in (notif.get('ReadBy') or [])
row = {
'id': str(notif.get('_id')),
'title': notif.get('Title', 'Benachrichtigung'),
'message': notif.get('Message', ''),
'severity': notif.get('Severity', 'info'),
'type': notif.get('Type', ''),
'created_at': notif.get('CreatedAt'),
'is_read': is_read,
'reference': notif.get('Reference', {}) or {},
}
if notif.get('Audience') == 'admin':
admin_notifications.append(row)
else:
user_notifications.append(row)
return render_template(
'notifications.html',
user_notifications=user_notifications,
admin_notifications=admin_notifications,
is_admin_user=is_admin_user,
library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
)
except Exception as exc:
app.logger.error(f"Error loading notifications: {exc}")
flash('Fehler beim Laden der Benachrichtigungen.', 'error')
return redirect(url_for('home'))
finally:
if client:
client.close()
@app.route('/notifications/mark_read/<notification_id>', methods=['POST'])
def mark_notification_read(notification_id):
"""Mark a single notification as read for the current user."""
if 'username' not in session:
return redirect(url_for('login'))
username = session['username']
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
result = db['notifications'].update_one(
{'_id': ObjectId(notification_id)},
{
'$addToSet': {'ReadBy': username},
'$set': {'UpdatedAt': datetime.datetime.now()}
}
)
if result.modified_count > 0:
_bump_notification_version(f'user:{username}')
except Exception as exc:
app.logger.warning(f"Could not mark notification as read {notification_id}: {exc}")
finally:
if client:
client.close()
return redirect(url_for('notifications_view'))
@app.route('/notifications/mark_all_read', methods=['POST'])
def mark_all_notifications_read():
"""Mark all visible notifications as read for the current user."""
if 'username' not in session:
return redirect(url_for('login'))
username = session['username']
is_admin_user = False
try:
is_admin_user = us.check_admin(username)
except Exception:
is_admin_user = False
query = {
'$or': [
{'Audience': 'user', 'TargetUser': username},
]
}
if is_admin_user:
query['$or'].append({'Audience': 'admin'})
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
result = db['notifications'].update_many(
query,
{
'$addToSet': {'ReadBy': username},
'$set': {'UpdatedAt': datetime.datetime.now()}
}
)
if result.modified_count > 0:
_bump_notification_version(f'user:{username}')
except Exception as exc:
app.logger.warning(f"Could not mark all notifications as read for {encrypt_text(username)}: {exc}")
finally:
if client:
client.close()
return redirect(url_for('notifications_view'))
@app.route('/notifications/unread_status', methods=['GET'])
def notifications_unread_status():
"""Return unread notification count and latest unread message metadata."""
if 'username' not in session:
return jsonify({'ok': False, 'error': 'not_authenticated'}), 401
username = session['username']
is_admin_user = False
try:
is_admin_user = us.check_admin(username)
except Exception:
is_admin_user = False
cached_payload, version_tag = _get_cached_unread_status(username, is_admin=is_admin_user)
if cached_payload is not None:
cached_etag = _build_unread_status_etag(version_tag, cached_payload)
return _build_cached_json_response(cached_payload, cached_etag)
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
visibility_query = {
'$or': [
{'Audience': 'user', 'TargetUser': username},
]
}
if is_admin_user:
visibility_query['$or'].append({'Audience': 'admin'})
unread_query = {
'$and': [
visibility_query,
{'ReadBy': {'$ne': username}},
]
}
unread_count = db['notifications'].count_documents(unread_query)
latest_unread = db['notifications'].find_one(
unread_query,
{
'Title': 1,
'Message': 1,
'CreatedAt': 1,
'Type': 1,
'Severity': 1,
},
sort=[('CreatedAt', -1)]
)
latest_payload = None
if latest_unread:
latest_payload = {
'title': latest_unread.get('Title', 'Benachrichtigung'),
'message': latest_unread.get('Message', ''),
'created_at': latest_unread.get('CreatedAt').isoformat() if isinstance(latest_unread.get('CreatedAt'), datetime.datetime) else '',
'type': latest_unread.get('Type', ''),
'severity': latest_unread.get('Severity', 'info'),
}
payload = {
'ok': True,
'unread_count': unread_count,
'latest_unread': latest_payload,
}
_set_cached_unread_status(username, is_admin_user, version_tag, payload)
etag_value = _build_unread_status_etag(version_tag, payload)
return _build_cached_json_response(payload, etag_value)
except Exception as exc:
app.logger.warning(f"Could not fetch unread notification status for {encrypt_text(username)}: {exc}")
return jsonify({'ok': False, 'error': 'status_fetch_failed'}), 500
finally:
if client:
client.close()
@app.route('/admin/damaged_items')
def admin_damaged_items():
"""Dedicated admin management window for damaged items."""
if 'username' not in session:
flash('Administratorrechte erforderlich.', 'error')
return redirect(url_for('login'))
'''
permissions = _get_current_user_permissions()
if not _action_access_allowed(permissions, 'can_manage_settings'):
flash('Sie haben keine Berechtigung, die Schulstammdaten zu ändern.', 'error')
if cfg.MODULES.is_enabled('library'):
return redirect(url_for('library_admin'))
'''
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
items_col = db['items']
ausleihungen_col = db['ausleihungen']
items = list(items_col.find(
{
'Deleted': {'$ne': True},
'$or': [
{'HasDamage': True},
{'Condition': 'destroyed'},
{'DamageReports.0': {'$exists': True}},
]
},
{
'Name': 1,
'Code_4': 1,
'ItemType': 1,
'Author': 1,
'ISBN': 1,
'Condition': 1,
'DamageReports': 1,
'DamageRepairs': 1,
'Verfuegbar': 1,
'User': 1,
'LastUpdated': 1,
}
).sort('LastUpdated', -1))
damaged_rows = []
for item_doc in items:
item_id = str(item_doc.get('_id'))
active_borrow = ausleihungen_col.find_one(
{'Item': item_id, 'Status': {'$in': ['active', 'planned']}},
{'_id': 1, 'User': 1, 'Status': 1, 'End': 1}
)
reports = item_doc.get('DamageReports', []) or []
latest_report = reports[0] if reports else {}
damaged_rows.append({
'id': item_id,
'name': item_doc.get('Name', ''),
'code': item_doc.get('Code_4', ''),
'item_type': item_doc.get('ItemType', ''),
'author': item_doc.get('Author', ''),
'isbn': item_doc.get('ISBN', ''),
'condition': item_doc.get('Condition', ''),
'available': bool(item_doc.get('Verfuegbar', False)),
'borrow_user': item_doc.get('User', ''),
'damage_count': len(reports),
'damage_reports': reports,
'latest_damage_description': latest_report.get('description', ''),
'latest_damage_by': latest_report.get('reported_by', ''),
'latest_damage_at': latest_report.get('reported_at'),
'active_borrow': active_borrow,
'last_updated': item_doc.get('LastUpdated'),
})
return render_template(
'admin_damaged_items.html',
damaged_items=damaged_rows,
library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
mail_module_enabled=cfg.MODULES.is_enabled('mail')
)
except Exception as exc:
app.logger.error(f"Error loading damaged-items admin view: {exc}")
flash('Fehler beim Laden der Defekte-Items-Verwaltung.', 'error')
return redirect(url_for('home_admin'))
finally:
if client:
client.close()
@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/<string: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.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards')
)
@app.route('/admin/school-settings', methods=['GET', 'POST'])
def admin_school_settings():
"""Admin page for configuring school metadata used in exports."""
if 'username' not in session:
flash('Bitte melden Sie sich mit einem administrativen Konto an.', 'error')
return redirect(url_for('login'))
if not us.check_admin(session['username']):
flash('Für diese Seite sind Administratorrechte erforderlich.', 'error')
return redirect(url_for('login'))
permissions = _get_current_user_permissions()
if not _action_access_allowed(permissions, 'can_manage_settings'):
flash('Sie haben keine Berechtigung, die Schulstammdaten zu ändern.', 'error')
return redirect(url_for('home_admin'))
current_school = _get_school_info_for_export()
tenant_context = None
tenant_id = None
tenant_db = cfg.MONGODB_DB
try:
tenant_context = get_tenant_context()
except Exception:
tenant_context = None
if tenant_context:
tenant_id = tenant_context.tenant_id
tenant_db = tenant_context.db_name or tenant_db
if request.method == 'POST':
school_info = {
'name': sanitize_form_value(request.form.get('name')),
'address': sanitize_form_value(request.form.get('address')),
'postal_code': sanitize_form_value(request.form.get('postal_code')),
'city': sanitize_form_value(request.form.get('city')),
'school_number': sanitize_form_value(request.form.get('school_number')),
'it_admin': sanitize_form_value(request.form.get('it_admin')),
'logo_path': sanitize_form_value(request.form.get('logo_path')),
'logo_thumb': sanitize_form_value(request.form.get('logo_thumb')),
}
uploaded_logo = request.files.get('logo_upload')
if uploaded_logo and getattr(uploaded_logo, 'filename', ''):
try:
previous_logo_path = current_school.get('logo_path') if isinstance(current_school, dict) else ''
previous_thumb_path = current_school.get('logo_thumb') if isinstance(current_school, dict) else ''
saved = _save_school_logo_upload(uploaded_logo, tenant_id=tenant_id, tenant_db=tenant_db)
if isinstance(saved, (list, tuple)):
saved_logo_filename, saved_thumb_filename = saved
else:
saved_logo_filename, saved_thumb_filename = saved, None
if saved_logo_filename:
school_info['logo_path'] = saved_logo_filename
school_info['logo_thumb'] = saved_thumb_filename or ''
# remove previous files if different
if previous_logo_path and previous_logo_path != saved_logo_filename:
previous_logo_file = os.path.join(app.config['UPLOAD_FOLDER'], previous_logo_path)
if os.path.exists(previous_logo_file):
try:
os.remove(previous_logo_file)
except Exception:
pass
if previous_thumb_path and previous_thumb_path != saved_thumb_filename:
previous_thumb_file = os.path.join(app.config['UPLOAD_FOLDER'], previous_thumb_path)
if os.path.exists(previous_thumb_file):
try:
os.remove(previous_thumb_file)
except Exception:
pass
except Exception as exc:
flash(f'Logo konnte nicht hochgeladen werden: {exc}', 'error')
return redirect(url_for('admin_school_settings'))
if not school_info.get('logo_path') and current_school.get('logo_path'):
school_info['logo_path'] = current_school.get('logo_path', '')
if not school_info.get('logo_thumb') and current_school.get('logo_thumb'):
school_info['logo_thumb'] = current_school.get('logo_thumb', '')
missing_fields = [
label for label, key in [
('Schulname', 'name'),
('Adresse', 'address'),
('PLZ', 'postal_code'),
('Ort', 'city'),
('Schulnummer', 'school_number'),
] if not school_info.get(key)
]
if missing_fields:
flash(f'Bitte füllen Sie die Pflichtfelder aus: {", ".join(missing_fields)}.', 'error')
else:
try:
updated_school = cfg.update_school_info(school_info)
current_school = updated_school
# Also process filter names
filter_name_1 = request.form.get('filter_name_1')
if filter_name_1: it.set_filter_name(1, filter_name_1.strip())
filter_name_2 = request.form.get('filter_name_2')
if filter_name_2: it.set_filter_name(2, filter_name_2.strip())
filter_name_3 = request.form.get('filter_name_3')
if filter_name_3: it.set_filter_name(3, filter_name_3.strip())
flash('Schulstammdaten wurden erfolgreich gespeichert.', 'success')
except Exception as exc:
app.logger.error(f'Could not update school settings: {exc}\n{traceback.format_exc()}')
flash('Die Schulstammdaten konnten nicht gespeichert werden.', 'error')
# Get current filter names to display in form
filter_names = it.get_filter_names()
return render_template(
'admin_school_settings.html',
school_info=current_school,
filter_names=filter_names,
tenant_id=tenant_id,
tenant_db=tenant_db,
library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
)
@app.route('/check_code_unique/<code>')
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:
app.logger.error(f"Error checking for booking conflicts: {e}")
return jsonify({'success': False, 'message': f'Fehler beim Prüfen der Verfügbarkeit'}), 500
# Check if the appointment should already be active
now = datetime.datetime.now()
initial_status = 'active' if start_datetime <= now else 'planned'
# Create the appointment
try:
# Use add_ausleihung directly to set the correct initial status
appointment_id = au.add_ausleihung(
item_id=item_id,
user=session['username'],
start_date=start_datetime,
end_date=end_datetime,
notes=notes,
status=initial_status,
period=booking_period # Will be None for multi-day
)
# If it became active immediately, log it and send a notification
if initial_status == 'active' and appointment_id:
app.logger.info(f"Appointment {appointment_id} scheduled retroactively as active.")
# Make the item unavailable since it is now actively borrowed
try:
it.update_item_status(item_id, False, session['username'])
except Exception as update_err:
app.logger.warning(f"Failed to update item status when retroactively activating: {update_err}")
# We can also notify the user right away
item_name = item.get('Name', 'Unbekannt')
# Log audit event
_append_audit_event_standalone(
event_type='ausleihung_started',
payload={
'borrow_id': str(appointment_id),
'item_id': item_id,
'item_name': item_name,
'user': session['username'],
'status_before': 'planned',
'status_after': 'active'
}
)
# Send notification
try:
client_temp = MongoClient(MONGODB_HOST, MONGODB_PORT)
db_temp = client_temp[MONGODB_DB]
_create_notification(
db_temp,
audience='user',
notif_type='appointment_activated',
title='Reservierung ist jetzt aktiv',
message=f"Deine geplante Ausleihe für {item_name} startet jetzt.",
target_user=session['username'],
reference={
'appointment_id': str(appointment_id),
'item_id': str(item_id),
'event': 'activated',
},
unique_key=f"appointment:activated:{appointment_id}",
severity='info'
)
client_temp.close()
except Exception as notif_err:
app.logger.error(f"Error sending immediate active notification: {notif_err}")
if not appointment_id:
return jsonify({'success': False, 'message': 'Termin konnte nicht erstellt werden'}), 500
except Exception as e:
app.logger.error(f"Error creating booking: {e}")
return jsonify({'success': False, 'message': f'Fehler beim Erstellen des Termins'}), 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:
app.logger.error(f"Error updating item with appointment info: {e}")
return jsonify({'success': False, 'message': f'Fehler beim Aktualisieren des Elements'}), 500
except Exception as e:
app.logger.error(f"Error creating appointment: {e}")
import traceback
return jsonify({'success': False, 'message': f'Serverfehler aufgetreten'}), 500
@app.route('/cancel_ausleihung/<id>', 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')
# If the booking was already active, make the item available again
item_id = str(ausleihung.get('Item')) if ausleihung.get('Item') is not None else None
if ausleihung_status == 'active' and item_id:
try:
it.update_item_status(item_id, True)
print(f"Restored availability of item {item_id} after active cancellation")
except Exception as status_err:
print(f"Warning: could not restore availability of item {item_id}: {status_err}")
_append_audit_event_standalone(
event_type='ausleihung_cancelled',
payload={
'borrow_id': id,
'item_id': str(ausleihung.get('Item') or ''),
'cancelled_by': username,
'owner_user': str(ausleihung_user or ''),
'status_before': str(ausleihung_status or ''),
}
)
# 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)
except Exception as clear_err:
app.logger.warning(f"Warning: could not clear NextAppointment for cancelled ausleihung {id}: {clear_err}")
else:
app.logger.warning(f"Failed to cancel ausleihung with ID: {id}")
flash('Fehler beim Stornieren der Ausleihung', 'error')
except Exception as e:
app.logger.warning(f"Error canceling ausleihung: {e}")
flash(f'Fehler', 'error')
return redirect(url_for('my_borrowed_items'))
@app.route('/reset_item/<id>', 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 Web.modules.database.ausleihung as au
result = au.reset_item_completely(id)
if result['success']:
return jsonify({
'success': True,
'message': 'Item reset successfully'
})
else:
return jsonify({
'success': False,
'message': 'Failed to reset item'
}), 400
except Exception as e:
print(f"Error in reset_item route: {e}")
import traceback
return jsonify({
'success': False,
'error': f'Serverfehler'
}), 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:
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:
app.logger.error(f"ffmpeg failed for {video_path}: {result.stderr}")
return False
except Exception as e:
app.logger.error(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)}")
# 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)}")
# 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/<path:filename>')
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/<filename>')
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/<filename>')
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)
def cleanup_old_optimized_images(max_age_days=30):
"""
Clean up old optimized images to save disk space.
Optimized images are re-created on demand, so old ones can be safely deleted.
Args:
max_age_days (int): Delete cached images older than this many days. Default 30.
Returns:
dict: Statistics about cleanup (deleted count, freed space in MB)
"""
try:
import time
import shutil
cache_dir = os.path.join(app.config['THUMBNAIL_FOLDER'], 'optimized_480p')
if not os.path.exists(cache_dir):
return {'deleted': 0, 'freed_mb': 0, 'error': None}
current_time = time.time()
max_age_seconds = max_age_days * 24 * 60 * 60
deleted_count = 0
freed_bytes = 0
for filename in os.listdir(cache_dir):
file_path = os.path.join(cache_dir, filename)
if not os.path.isfile(file_path):
continue
file_age_seconds = current_time - os.path.getmtime(file_path)
if file_age_seconds > max_age_seconds:
try:
file_size = os.path.getsize(file_path)
os.remove(file_path)
deleted_count += 1
freed_bytes += file_size
except Exception as e:
app.logger.warning(f"Failed to delete optimized image {filename}: {str(e)}")
freed_mb = freed_bytes / (1024 * 1024)
app.logger.info(f"Cleanup complete: Deleted {deleted_count} images, freed {freed_mb:.2f} MB")
return {
'deleted': deleted_count,
'freed_mb': round(freed_mb, 2),
'error': None
}
except Exception as e:
app.logger.error(f"Error during optimized image cleanup: {str(e)}")
return {'deleted': 0, 'freed_mb': 0}
@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})
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 were within 10% of target, thats good enough
if abs(size - target_size_bytes) < (target_size_bytes * 0.1):
return quality
return best_quality
# ============================================================================
# PUSH NOTIFICATION API ENDPOINTS
# ============================================================================
@app.route('/health')
def health_check():
return 'OK', 200
@app.route('/api/push/subscribe', methods=['POST'])
def subscribe_to_push():
"""
Subscribe a user to push notifications
Expects JSON payload:
{
'subscription': {
'endpoint': '...',
'keys': {'p256dh': '...', 'auth': '...'}
}
}
"""
if 'username' not in session:
return jsonify({'success': False, 'error': 'Not authenticated'}), 401
try:
data = request.get_json() or {}
subscription = data.get('subscription')
if not subscription or not subscription.get('endpoint'):
return jsonify({'success': False, 'error': 'Invalid subscription'}), 400
username = session['username']
success = pn.save_push_subscription(username, subscription)
if success:
app.logger.info(f'Push subscription saved for {encrypt_text(username)}')
return jsonify({
'success': True,
'message': 'Successfully subscribed to push notifications'
})
else:
return jsonify({'success': False, 'error': 'Failed to save subscription'}), 500
except Exception as e:
app.logger.error(f'Error subscribing to push: {e}')
return jsonify({'success': False}), 500
@app.route('/api/push/unsubscribe', methods=['POST'])
def unsubscribe_from_push():
"""
Unsubscribe a user from push notifications
Expects JSON payload:
{
'endpoint': '...'
}
"""
if 'username' not in session:
return jsonify({'success': False, 'error': 'Not authenticated'}), 401
try:
data = request.get_json() or {}
endpoint = data.get('endpoint')
if not endpoint:
return jsonify({'success': False, 'error': 'Missing endpoint'}), 400
username = session['username']
success = pn.remove_push_subscription(username, endpoint)
if success:
app.logger.info(f'Push subscription removed for {encrypt_text(username)}')
return jsonify({
'success': True,
'message': 'Successfully unsubscribed from push notifications'
})
else:
return jsonify({'success': False, 'error': 'Subscription not found'}), 404
except Exception as e:
app.logger.error(f'Error unsubscribing from push: {e}')
return jsonify({'success': False}), 500
@app.route('/api/push/subscriptions', methods=['GET'])
def get_push_subscriptions():
"""
Get all push subscriptions for the current user
"""
if 'username' not in session:
return jsonify({'success': False, 'error': 'Not authenticated'}), 401
try:
username = session['username']
subscriptions = pn.get_user_subscriptions(username)
# Convert ObjectIds to strings for JSON serialization
subs_data = []
for sub in subscriptions:
subs_data.append({
'id': str(sub['_id']),
'endpoint': sub.get('Endpoint', ''),
'created_at': sub.get('CreatedAt', '').isoformat() if sub.get('CreatedAt') else '',
'last_used': sub.get('LastUsed', '').isoformat() if sub.get('LastUsed') else '',
'user_agent': sub.get('UserAgent', ''),
})
return jsonify({
'success': True,
'subscriptions': subs_data,
'count': len(subs_data)
})
except Exception as e:
app.logger.error(f'Error getting push subscriptions: {e}')
return jsonify({'success': False}), 500
@app.route('/api/push/vapid-key', methods=['GET'])
def get_vapid_key():
"""
Get the VAPID public key for push notifications
Used by the service worker to communicate with push service
"""
try:
vapid_key = pn.VAPID_PUBLIC_KEY
if not vapid_key:
app.logger.warning('VAPID_PUBLIC_KEY not configured')
return jsonify({
'success': False,
'error': 'Push notifications not configured on server'
}), 501
return jsonify({
'success': True,
'vapid_key': vapid_key
})
except Exception as e:
app.logger.error(f'Error getting VAPID key: {e}')
return jsonify({'success': False}), 500
@app.route('/api/push/test', methods=['POST'])
def test_push_notification():
"""
Send a test push notification (admin only)
"""
if 'username' not in session:
return jsonify({'success': False, 'error': 'Not authenticated'}), 401
if not us.is_admin(session['username']):
return jsonify({'success': False, 'error': 'Admin access required'}), 403
try:
data = request.get_json() or {}
target_user = data.get('target_user', session['username'])
sent = pn.send_push_notification(
target_user,
'Test Benachrichtigung',
'Dies ist eine Test-Benachrichtigung von Inventarsystem',
url='/',
tag='test-notification'
)
if sent > 0:
return jsonify({
'success': True,
'message': f'Test push sent to {sent} subscription(s)'
})
else:
return jsonify({
'success': False,
'error': 'No active subscriptions for user'
}), 404
except Exception as e:
app.logger.error(f'Error sending test push: {e}')
return jsonify({'success': False}), 500