Compare commits

...

14 Commits

Author SHA1 Message Date
Aiirondev_dev 6985f32fe9 fix(scheduler): eliminate worker race conditions using MongoDB lock
Release Inventarsystem / release-docker (push) Successful in 3m20s
- Replace fragile .scheduler_lock file mechanism with atomic MongoDB operations
- Prevent multiple background schedulers from starting across web workers
- Add heartbeat task to renew lock periodically and handle crash recovery
- Release lock cleanly on application shutdown
2026-08-17 15:49:51 +02:00
Aiirondev_dev ab7a369b6e removal of automatic return of files
Release Inventarsystem / release-docker (push) Failing after 34s
2026-08-17 15:31:22 +02:00
Aiirondev_dev 27be38dfc9 Spelling enhancements
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-17 13:33:51 +02:00
Aiirondev_dev 140fb5f743 Implementation of an automatic code generation and generation of an excel.
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-17 12:18:19 +02:00
Aiirondev_dev 9ea0ae6157 Implementation of an automatic id code generation.
Release Inventarsystem / release-docker (push) Successful in 3m12s
2026-08-17 11:50:35 +02:00
Aiirondev_dev bb89b434ed Error fixing and debugging implementation.
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-17 01:07:38 +02:00
Aiirondev_dev ac4d125d73 Error fixing and debugging implementation.
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-17 00:57:18 +02:00
Aiirondev_dev 3f6830e8c8 Error fixing and debugging implementation.
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-17 00:50:35 +02:00
Aiirondev_dev 0ea5d2db26 Error fixing and debugging implementation.
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-17 00:39:38 +02:00
Aiirondev_dev 41d9c0a848 Style fix for the detailed galery view.
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-17 00:19:07 +02:00
Aiirondev_dev f6e3db9b4a Style fix for the detailed galery view.
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-17 00:11:39 +02:00
Aiirondev_dev 7e5ee7b5ea Slight fix of the displaying of the detailed view, for the bibliothek focusing on the style
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-17 00:02:49 +02:00
Aiirondev_dev 2528e79895 Slight fix of the displaying of the detailed view, for the bibliothek
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-16 23:50:24 +02:00
Aiirondev_dev 542caa520f fix of a redirect error
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-16 23:21:31 +02:00
3 changed files with 239 additions and 164 deletions
+147 -131
View File
@@ -16,6 +16,7 @@ Features:
- History logging of item usage
- Booking and reservation of items
"""
from random import random
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
@@ -71,9 +72,6 @@ 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
@@ -125,10 +123,6 @@ app.register_blueprint(terminplaner_bp, url_prefix='/terminplaner')
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:
@@ -1459,7 +1453,7 @@ def update_appointment_statuses():
- Geplante Termine, die aktiviert werden sollten
- Aktive Termine, die beendet werden sollten
"""
current_time = datetime.datetime.now()
current_time = datetime.datetime.now(datetime.timezone.utc)
try:
# Hole alle Termine mit Status 'planned' oder 'active'
@@ -1485,7 +1479,7 @@ def update_appointment_statuses():
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:
if new_status != old_status and not it.is_library_item(appointment.get('Item')):
extra_fields = {}
# --- Conflict resolver: planned → active transition ---
@@ -1591,6 +1585,9 @@ def update_appointment_statuses():
app.logger.warning(
f"Failed to create activation notification for {appointment.get('_id')}: {notif_err}"
)
elif it.is_library_item(appointment.get('Item')):
# Introduction of an messaging system and a Mahnstufen implementation for the library Book bookings after the designatet time.
pass
client.close()
@@ -1602,78 +1599,116 @@ def update_appointment_statuses():
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
# Initialize scheduler instances
scheduler = BackgroundScheduler()
_scheduler_initialized = False
_scheduler_worker_id = None # Tracks the unique ID of the worker holding the lock
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:
"""Initialize the background scheduler safely using an atomic MongoDB lock."""
global _scheduler_initialized, _scheduler_worker_id
if _scheduler_initialized or not getattr(cfg, 'SCHEDULER_ENABLED', False):
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}")
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
locks_col = db['system_locks']
# 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}")
# 1. Generate a unique ID for this specific worker process
_scheduler_worker_id = str(uuid.uuid4())
now = datetime.datetime.now(datetime.timezone.utc)
# 2. Ensure the lock document exists (initialize if missing)
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")
locks_col.insert_one({
'_id': 'scheduler_lock',
'locked_at': datetime.datetime.min.replace(tzinfo=datetime.timezone.utc),
'worker_id': None
})
except DuplicateKeyError:
pass # Document already exists, which is expected
if should_start:
# 3. Try to acquire the lock atomically
# We only acquire if the current lock is older than 5 minutes (stale/crashed worker)
# or if it was explicitly released (1970 init date)
lock_timeout = now - datetime.timedelta(minutes=5)
acquired = locks_col.find_one_and_update(
{
'_id': 'scheduler_lock',
'locked_at': {'$lt': lock_timeout}
},
{
'$set': {
'locked_at': now,
'worker_id': _scheduler_worker_id
}
}
)
if acquired is not None:
# 4. Lock acquired successfully - this is the master worker
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)
# 5. Add a heartbeat job to keep the lock alive
def renew_lock_heartbeat():
try:
hb_client = MongoClient(MONGODB_HOST, MONGODB_PORT)
hb_db = hb_client[MONGODB_DB]
hb_db['system_locks'].update_one(
{'_id': 'scheduler_lock', 'worker_id': _scheduler_worker_id},
{'$set': {'locked_at': datetime.datetime.now(datetime.timezone.utc)}}
)
hb_client.close()
except Exception as e:
app.logger.error(f"Scheduler heartbeat failed: {e}")
# Run heartbeat every 2 minutes (safely below the 5-minute timeout)
scheduler.add_job(func=renew_lock_heartbeat, trigger="interval", minutes=2)
scheduler.start()
_scheduler_initialized = True
app.logger.info(f"Scheduler started successfully (interval={cfg.SCHEDULER_INTERVAL_MIN} min)")
app.logger.info(f"Scheduler started successfully (Worker ID: {_scheduler_worker_id})")
else:
app.logger.info("Scheduler skipped - another worker instance is running it")
app.logger.info("Scheduler skipped - another active worker holds the MongoDB lock")
client.close()
except Exception as e:
app.logger.error(f"Failed to initialize scheduler: {e}")
app.logger.error(f"Failed to initialize scheduler with MongoDB lock: {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:
"""Gracefully shut down the scheduler and release the MongoDB lock."""
global _scheduler_initialized, _scheduler_worker_id
if getattr(cfg, 'SCHEDULER_ENABLED', False) 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
scheduler.shutdown(wait=False)
# Release the lock so a newly spawned worker can immediately take over
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
db['system_locks'].update_one(
{'_id': 'scheduler_lock', 'worker_id': _scheduler_worker_id},
{'$set': {'locked_at': datetime.datetime.min.replace(tzinfo=datetime.timezone.utc)}}
)
client.close()
app.logger.info(f"Scheduler shut down and lock released (Worker ID: {_scheduler_worker_id})")
except Exception as e:
app.logger.error(f"Error during scheduler shutdown: {e}")
atexit.register(_shutdown_scheduler)
"""-------------------------------------------------------------File Upload Validation----------------------------------------------------------------------------- """
@@ -2101,6 +2136,20 @@ def _build_student_card_excel_id(student_name, class_name, row_number, used_ids)
return candidate
def generate_ausweis_id_excel(existing_ids_set):
"""Generates a unique ID and checks against DB and current import queue."""
while True:
random_digits = "".join(random.choices(string.digits, k=6))
new_id = f"ID_{random_digits}"
# Prüfe sowohl in den bereits in dieser Session generierten IDs als auch in der DB
if new_id.upper() not in existing_ids_set and not us.get_user_by_student_card(new_id):
print(f"Generated unique ID: {new_id}")
return new_id
else:
print(f"Already found: {new_id}, trying another...")
def _upload_student_cards_excel():
"""Bulk import student cards from Excel with automatic name/class mapping."""
if 'username' not in session:
@@ -2141,12 +2190,12 @@ def _upload_student_cards_excel():
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'],
'default_borrow_days': ['standard_ausleihdauer', 'ausleihdauer', 'borrow_days', 'tage', 'leihtage',
'max_borrow_days'],
}
def col_index(key):
@@ -2158,7 +2207,6 @@ def _upload_student_cards_excel():
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'),
@@ -2177,10 +2225,11 @@ def _upload_student_cards_excel():
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
try:
db = client[cfg.MONGODB_DB]
student_cards = list(db['student_cards'].find())
student_cards_cursor = db['student_cards'].find({}, {'AusweisId': 1})
existing_ids.update(
str(card.get('AusweisId', '')).strip().upper()
for card in student_cards.find({}, {'AusweisId': 1})
for card in student_cards_cursor
if card.get('AusweisId')
)
@@ -2198,27 +2247,29 @@ def _upload_student_cards_excel():
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'))
default_borrow_days = _excel_int(val('default_borrow_days'))
if not default_borrow_days:
default_borrow_days = 14
first_name = sanitize_form_value(val('first_name')) or ""
last_name = sanitize_form_value(val('last_name')) or ""
student_name = f"{first_name} {last_name}".strip()
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')
row_errors.append('Vorname und Nachname fehlen')
if not ausweis_id and student_name:
ausweis_id = _build_student_card_excel_id(student_name, class_name, row_number, existing_ids)
ausweis_id = generate_ausweis_id_excel(existing_ids)
validation_warnings.append((row_number, f'Ausweis-ID wurde automatisch erzeugt: {ausweis_id}'))
existing_ids.add(ausweis_id.upper())
elif ausweis_id:
ausweis_id = str(ausweis_id).strip().upper()
if ausweis_id in existing_ids:
@@ -3826,11 +3877,14 @@ def api_item_detail(item_id):
{f'<p><strong>Ausgeliehen von:</strong> {html.escape(str(borrower_value))}</p>' if borrower_value and status_label == 'Ausgeliehen' else ''}
{borrows_html}
"""
ctx = get_tenant_context()
current_tenant_id = ctx.tenant_id if ctx else None
client.close()
return jsonify({
'html': detail_html,
'images': item.get('Images', item.get('Bilder', []))
'images': item.get('Images', item.get('Bilder', [])),
'tenant': str(current_tenant_id)
}), 200
except Exception as e:
app.logger.error(f"Error fetching item detail: {e}")
@@ -3982,6 +4036,19 @@ def library_admin():
back_target='library'
)
def generate_ausweis_id():
import random
import string
while True:
random_digits = "".join(random.choices(string.digits, k=6))
new_id = f"ID_{random_digits}"
if not us.get_user_by_student_card(new_id):
print(f"Generated unique ID: {new_id}")
return new_id
else:
print(f"Already found: {new_id}, trying another...")
@app.route('/student_cards_admin', methods=['GET', 'POST'])
def student_cards_admin():
@@ -4082,13 +4149,18 @@ def student_cards_admin():
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')
if not student_name:
flash('Bitte Schülername angeben.', 'error')
else:
# Check if ID already exists
existing = student_cards.find_one({'AusweisId': ausweis_id})
if ausweis_id:
existing = student_cards.find_one({'AusweisId': ausweis_id})
else:
ausweis_id = generate_ausweis_id()
existing = False
if existing:
flash('Diese Ausweis-ID existiert bereits.', 'error')
flash('Diese ID existiert bereits.', 'error')
else:
try:
encrypted_payload = encrypt_document_fields(
@@ -5484,7 +5556,7 @@ def upload_item():
fs = get_gridfs()
if cfg.MODULES.is_enabled('library') and sanitize_form_value(request.form.get('item_type_input', '')) != "other":
success_redirect_endpoint = 'library'
success_redirect_endpoint = 'library_view'
else:
success_redirect_endpoint = 'home_admin'
@@ -5830,7 +5902,7 @@ def upload_item():
success_msg = f'Element wurde erfolgreich hinzugefügt ({len(created_item_ids)} erstellt)'
fs = get_gridfs() # Deine GridFS Verbindung
cleanup_orphaned_images(fs, dry_run=True)
it.cleanup_orphaned_images(fs, dry_run=True)
if upload_mode == 'library':
try:
_append_audit_event_standalone(
@@ -7244,62 +7316,6 @@ def check_availability():
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():
"""
+89 -30
View File
@@ -450,6 +450,37 @@
width: 100%;
height: 100%;
}
.detail-gallery-container {
display: flex;
flex-wrap: wrap;
gap: 15px;
margin: 15px 0 20px 0;
width: 100%;
align-items: center;
}
.item-image-wrapper {
background-color: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
}
.item-image {
max-width: 160px;
max-height: 200px;
width: auto;
height: auto;
display: block;
object-fit: contain;
border-radius: 4px;
}
</style>
<div class="library-table-container" id="libraryTableContainer" data-can-edit="{{ 1 if current_permissions.actions.get('can_edit', False) else 0 }}">
@@ -1328,43 +1359,71 @@
const detailContent = document.getElementById('detailContent');
const detailModal = document.getElementById('detailModal');
// Lade-Status anzeigen und Modal öffnen
detailContent.innerHTML = '<p>Lade Details...</p>';
detailModal.style.display = 'flex';
// Sicherer Zugriff auf das Item mit Fallback
const item = libraryItems.find(i => i._id === itemId);
let mediaHtml = '';
if (item) {
// Prüfe gängige Array-Namen aus dem Backend
const imageArray = item.Images || item.Bilder || item.images;
if (Array.isArray(imageArray) && imageArray.length > 0) {
const imagesHtml = imageArray.map(image => {
// Direkter, robuster Pfad zur Upload-Route
const imageSrc = image.startsWith('/uploads/') || image.startsWith('http')
? image
: `/uploads/${image}`;
return `<img src="${imageSrc}" alt="${escapeHtml(item.Name || 'Medium')}" class="item-image" style="width: 120px; height: 120px; object-fit: cover; border-radius: 8px; border: 1px solid #ddd; margin-right: 15px; margin-bottom: 15px;">`;
}).join('');
mediaHtml = `<div class="detail-gallery-container" style="margin-bottom: 20px; padding-bottom: 15px; border-bottom: 1px solid #eee; display: flex; flex-wrap: wrap;">${imagesHtml}</div>`;
}
}
// Zusätzliche Details vom Backend laden
// Daten vom Backend-API-Endpoint abrufen
fetch(`/api/item_detail/${itemId}`)
.then(response => {
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.text();
if (!response.ok) {
throw new Error('Fehler beim Laden der Artikeldetails');
}
return response.json();
})
.then(html => {
detailContent.innerHTML = mediaHtml + DOMPurify.sanitize(html);
.then(data => {
detailContent.innerHTML = data.html;
const imageArray = data.images || [];
const thumbnailInfoMap = data.thumbnailInfo || [];
if (Array.isArray(imageArray) && imageArray.length > 0) {
const imagesHtml = imageArray.map((image, index) => {
const isVideo = typeof isVideoFile === 'function' ? isVideoFile(image) : /\.(mp4|webm|ogg|mov)$/i.test(image);
const thumbnailInfo = thumbnailInfoMap[index];
if (isVideo) {
const videoSrc = image.startsWith('/uploads/') || image.startsWith('http')
? image
: `/uploads/${image}`;
return `
<div class="item-image-wrapper" style="width: 100%; height: auto;">
<video src="${videoSrc}" class="item-image ${index === 0 ? 'active-image' : ''}" id="modal-image-${index}" controls preload="metadata" style="width: 100%; height: auto; max-height: 300px;"></video>
</div>`;
} else {
const imageSrc = thumbnailInfo && thumbnailInfo.has_preview
? thumbnailInfo.preview_url
: (image.startsWith('/uploads/') || image.startsWith('http')
? image
: `/uploads/${image}`);
return `
<div class="item-image-wrapper">
<img src="${imageSrc}"
alt="Buchcover / Bild"
class="item-image ${index === 0 ? 'active-image' : ''}"
id="modal-image-${index}"
loading="lazy"
onload="console.log('Bild geladen:', '${imageSrc}')"
onerror="console.error('FEHLER beim Laden des Bildes im DOM:', '${imageSrc}')">
</div>`;
}
}).join('');
const mediaHtml = `<div class="detail-gallery-container">${imagesHtml}</div>`;
const h2Tag = detailContent.querySelector('h2');
if (h2Tag) {
h2Tag.insertAdjacentHTML('afterend', mediaHtml);
} else {
detailContent.insertAdjacentHTML('afterbegin', mediaHtml);
}
}
})
.catch(err => {
console.error('Error loading detail:', err);
detailContent.innerHTML = '<p style="color: red;">Entschuldigung, die Details konnten nicht geladen werden.</p>';
.catch(error => {
console.error('Error fetching item detail:', error);
detailContent.innerHTML = '<p style="color: red;">Fehler beim Laden der Artikeldetails.</p>';
});
}
+3 -3
View File
@@ -249,7 +249,7 @@
<div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
<h3 style="margin:0 0 8px 0;">Excel-Import Bibliotheksausweise</h3>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch, zum Beispiel aus <strong>ASV (Amtliche Schuldaten)</strong>. Erkannt werden automatisch Spalten wie <strong>Name</strong>, <strong>Klasse</strong>, <strong>Ausweis-ID</strong>, <strong>Notizen</strong> und <strong>Standard-Ausleihdauer</strong>. Fehlt die Ausweis-ID, wird sie automatisch aus Name und Klasse erzeugt.</p>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch, zum Beispiel aus <strong>ASV (Amtliche Schuldaten)</strong>. Erkannt werden automatisch Spalten wie <strong>Name</strong>, <strong>Nachname</strong, <strong>Klasse</strong>, <strong>Ausweis-ID (optional)</strong>, <strong>Notizen (optional)</strong> und <strong>Standard-Ausleihdauer (optional)</strong>.</p>
<form method="POST" action="{{ url_for('upload_student_cards_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<input type="file" name="student_cards_excel" accept=".xlsx,.csv" required>
<button type="submit" class="btn btn-secondary" name="excel_action" value="validate">Nur validieren</button>
@@ -271,8 +271,8 @@
<div class="form-row">
<div class="form-group">
<label for="ausweis_id">Ausweis-ID *</label>
<input type="text" id="ausweis_id" name="ausweis_id" required
<label for="ausweis_id">Ausweis-ID</label>
<input type="text" id="ausweis_id" name="ausweis_id"
value="{{ form_data.get('ausweis_id', '') }}"
placeholder="z.B. SIS2024001">
</div>