Compare commits

..

25 Commits

Author SHA1 Message Date
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
Aiirondev_dev 5136e40587 Implementation of a clean up function for a stray collection processing
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-16 23:08:10 +02:00
Aiirondev_dev c62b2b553d improvements in processing the library item uploading
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-16 22:50:17 +02:00
Aiirondev_dev 8783f97a09 improvements in displaying the Images in the detailed view
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-16 22:19:41 +02:00
Aiirondev_dev 43e09b41f1 Slight change to the Style for the scnaner
Release Inventarsystem / release-docker (push) Successful in 2m16s
2026-08-16 21:23:04 +02:00
Aiirondev_dev 84257dc289 Fix / implementation of the according wrapper for the scanner processing
Release Inventarsystem / release-docker (push) Successful in 2m18s
2026-08-16 21:07:47 +02:00
Aiirondev_dev c4b3850369 Merge remote-tracking branch 'origin/main'
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-16 20:06:12 +02:00
Aiirondev_dev 28e9487fe1 Improved implementation of the Klassen processing to havbe the already existing ones in a dropdown. 2026-08-16 20:06:05 +02:00
Aiirondev_dev fe075938d7 resolve camara scan stream failure
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-16 15:57:12 +02:00
Aiirondev_dev 0ef928efd8 slight fix of the scanner
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-16 15:50:48 +02:00
Aiirondev_dev 1299140823 Fix of the encryption processing with the Student class
Release Inventarsystem / release-docker (push) Successful in 2m14s
2026-08-16 13:17:20 +02:00
Aiirondev_dev ea48d5c28a decryption of the parsed fields
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-16 13:08:34 +02:00
Aiirondev_dev 514065f4af fixe of a list error
Release Inventarsystem / release-docker (push) Successful in 3m7s
2026-08-16 12:58:31 +02:00
Aiirondev_dev 90da8487f2 feat(student-cards): add class-specific PDF export with dynamic dropdown
Release Inventarsystem / release-docker (push) Successful in 2m16s
- Implement /student_card_class_barcode_download route to filter student cards by class
- Dynamically extract unique class names from the database for frontend selection
- Update student_cards_admin.html to replace the text input with a class selection dropdown
2026-08-15 18:10:05 +02:00
5 changed files with 882 additions and 399 deletions
+362 -96
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
@@ -2101,6 +2102,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 +2156,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 +2173,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 +2191,11 @@ def _upload_student_cards_excel():
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
try:
db = client[cfg.MONGODB_DB]
student_cards = db['student_cards']
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 +2213,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,9 +3843,15 @@ 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 detail_html, 200
return jsonify({
'html': detail_html,
'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}")
return jsonify({'error': 'An error occurred while fetching the item detail'}), 500
@@ -3979,6 +4002,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():
@@ -4079,13 +4115,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
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(
@@ -4108,20 +4149,21 @@ def student_cards_admin():
app.logger.error(f"Error adding student card: {e}")
flash('Fehler beim Hinzufügen des Ausweises.', 'error')
# Get all student cards
# Get all student cards and decrypt them
all_cards = list(student_cards.find().sort('AusweisId', 1))
all_cards = [_decrypt_student_card_doc(card) for card in all_cards]
client.close()
raw_classes = set(card.get('Klasse', '').strip() for card in all_cards if card.get('Klasse'))
available_classes = sorted(list(raw_classes))
return render_template(
'student_cards_admin.html',
username=session['username'],
student_cards=all_cards,
student_cards=all_cards, # Hier all_cards übergeben
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')
available_classes=available_classes, # Enthält nun die lesbaren Klassen
config=cfg.get_school_info()
)
@@ -4215,6 +4257,7 @@ def student_card_barcode_download():
from reportlab.pdfgen import canvas
from reportlab.lib.units import mm
from reportlab.lib.colors import HexColor, white, black
from reportlab.pdfbase.pdfmetrics import stringWidth
from io import BytesIO
import barcode
from barcode.writer import ImageWriter
@@ -4287,40 +4330,48 @@ def student_card_barcode_download():
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)
school_name_str = str(school_name["name"])
# Zwei-Zeilen-Header: 1. BIBLIOTHEKSAUSWEIS, 2. Schulname (dynamisch)
c.setFont("Helvetica-Bold", 8)
c.setFillColor(white)
c.drawString(x_pos + 3*mm, y_pos - 6.5*mm, f"BIBLIOTHEKSAUSWEIS - {str(school_name)}")
c.drawString(x_pos + 3 * mm, y_pos - 4 * mm, "BIBLIOTHEKSAUSWEIS")
# Dynamische Anpassung des Schulnamens an die Breite
font_size = 8
while stringWidth(school_name_str, "Helvetica-Bold", font_size) > (card_width - 6 * mm) and font_size > 4:
font_size -= 0.5
c.setFont("Helvetica-Bold", font_size)
c.drawString(x_pos + 3 * mm, y_pos - 8 * mm, school_name_str)
# 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)
c.drawString(x_pos + 3 * mm, y_pos - 16 * 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.drawString(x_pos + 3 * mm, y_pos - 20 * 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']))
c.drawString(x_pos + 3 * mm, y_pos - 23 * 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.drawString(x_pos + 3 * mm, y_pos - 27 * 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, "-")
c.drawString(x_pos + 3 * mm, y_pos - 30 * mm, card['Klasse'])
c.drawString(x_pos + 3 * mm, y_pos - 33 * mm, "-")
c.drawString(x_pos + 3 * mm, y_pos - 36 * mm, "-")
c.drawString(x_pos + 3 * mm, y_pos - 39 * mm, "-")
c.drawString(x_pos + 3 * mm, y_pos - 42 * mm, "-")
# Right barcode section with border highlight
barcode_x_start = x_pos + info_width + 1 * mm
@@ -4329,28 +4380,34 @@ def student_card_barcode_download():
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
# Generate and add dynamically sized 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
# ImageWriter Settings für maximalen Platz und Lesbarkeit
options = {
'write_text': False, # Text entfernen (spart Platz)
'quiet_zone': 1.0, # Minimaler Rand
'dpi': 300 # Hohe Auflösung
}
barcode_obj = barcode.get('code128', str(card['AusweisId']), writer=ImageWriter())
barcode_obj.save(barcode_path)
barcode_obj.save(barcode_path, options=options)
barcode_file = f"{barcode_path}.png"
if os.path.exists(barcode_file):
# Larger barcode taking up most of right section
# Barcode über den fast kompletten restlichen Bereich strecken
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
barcode_height = card_height - 15 * mm
barcode_y = y_pos - card_height + 2.5 * mm
c.drawImage(barcode_file,
barcode_x_start + 1 * mm,
barcode_y,
width=barcode_width,
height=barcode_height,
preserveAspectRatio=True)
preserveAspectRatio=False) # Strecken erlauben für maximalen Scanbereich
os.remove(barcode_file)
else:
raise Exception("Barcode file not created")
@@ -4378,6 +4435,196 @@ def student_card_barcode_download():
return redirect(url_for('student_cards_admin'))
@app.route('/student_card_class_barcode_download', methods=['GET'])
def student_card_class_barcode_download():
"""
Download PDF with student card barcodes filtered by a specific class from dropdown.
"""
from flask import request, session, redirect, url_for, send_file, flash
if 'username' not in session:
return redirect(url_for('login'))
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['actions'].get('can_manage_users', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
return redirect(url_for('library_view'))
class_name = request.args.get('class_name', '').strip()
if not class_name:
flash('Bitte wählen Sie eine Klasse aus.', 'error')
return redirect(url_for('student_cards_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
from reportlab.pdfbase.pdfmetrics import stringWidth
from io import BytesIO
import barcode
from barcode.writer import ImageWriter
import tempfile
import os
import datetime
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
student_cards = db['student_cards']
# 1. Alle Ausweise laden
all_cards = list(student_cards.find().sort('AusweisId', 1))
client.close()
# 2. Alle entschlüsseln
decrypted_cards = [_decrypt_student_card_doc(card) for card in all_cards]
# 3. Im Speicher nach der Klartext-Klasse filtern
class_cards = [
card for card in decrypted_cards
if card.get('Klasse', '').strip() == class_name
]
if not class_cards:
flash(f'Keine Ausweise für die Klasse "{class_name}" 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 = 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
header_color = HexColor("#0F172A")
accent_color = HexColor("#2563EB")
light_bg = HexColor("#F8FAFC")
card_bg_color = HexColor("#FFFFFF")
text_dark = HexColor("#1E293B")
text_gray = HexColor("#64748B")
for i, card in enumerate(class_cards):
if col_idx == 0 and i > 0:
y_pos -= (card_height + gap_y)
if y_pos - card_height < margin:
c.showPage()
y_pos = y_start
col_idx = 0
x_pos = x_positions[col_idx]
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)
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)
info_width = 38 * mm
c.setFillColor(light_bg)
c.rect(x_pos, y_pos - card_height, info_width, card_height, fill=1, stroke=0)
c.setFillColor(header_color)
c.rect(x_pos, y_pos - 10 * mm, card_width, 10 * mm, fill=1, stroke=0)
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_str = str(school_name["name"])
c.setFont("Helvetica-Bold", 8)
c.setFillColor(white)
c.drawString(x_pos + 3 * mm, y_pos - 4 * mm, "BIBLIOTHEKSAUSWEIS")
font_size = 8
while stringWidth(school_name_str, "Helvetica-Bold", font_size) > (card_width - 6 * mm) and font_size > 4:
font_size -= 0.5
c.setFont("Helvetica-Bold", font_size)
c.drawString(x_pos + 3 * mm, y_pos - 8 * mm, school_name_str)
c.setFillColor(text_dark)
c.setFont("Helvetica-Bold", 10)
name = card.get('SchülerName', '')[:20]
c.drawString(x_pos + 3 * mm, y_pos - 16 * mm, name)
c.setFont("Helvetica", 8)
c.setFillColor(text_gray)
c.drawString(x_pos + 3 * mm, y_pos - 20 * mm, "Ausweis ID:")
c.setFillColor(text_dark)
c.setFont("Helvetica-Bold", 9)
c.drawString(x_pos + 3 * mm, y_pos - 23 * mm, str(card.get('AusweisId', '')))
if card.get('Klasse'):
c.setFont("Helvetica", 8)
c.setFillColor(text_gray)
c.drawString(x_pos + 3 * mm, y_pos - 27 * mm, "Klasse:")
c.setFillColor(text_dark)
c.setFont("Helvetica-Bold", 9)
c.drawString(x_pos + 3 * mm, y_pos - 30 * mm, card['Klasse'])
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)
try:
temp_dir = tempfile.gettempdir()
barcode_path = os.path.join(temp_dir, f"barcode_{card.get('AusweisId', i)}")
options = {
'write_text': False,
'quiet_zone': 1.0,
'dpi': 300
}
barcode_obj = barcode.get('code128', str(card.get('AusweisId', '')), writer=ImageWriter())
barcode_obj.save(barcode_path, options=options)
barcode_file = f"{barcode_path}.png"
if os.path.exists(barcode_file):
barcode_width = (card_width - info_width - 4 * mm)
barcode_height = card_height - 15 * mm
barcode_y = y_pos - card_height + 2.5 * mm
c.drawImage(barcode_file, barcode_x_start + 1 * mm, barcode_y,
width=barcode_width, height=barcode_height, preserveAspectRatio=False)
os.remove(barcode_file)
except Exception as e:
pass
col_idx += 1
if col_idx >= cols:
col_idx = 0
c.save()
pdf_buffer.seek(0)
filename = f'ausweise_klasse_{class_name.replace(" ", "_")}_{datetime.datetime.now().strftime("%Y%m%d")}.pdf'
return send_file(
pdf_buffer,
mimetype='application/pdf',
as_attachment=True,
download_name=filename
)
except Exception as 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):
"""
@@ -4399,6 +4646,7 @@ def student_card_single_barcode_download(card_id):
from reportlab.pdfgen import canvas
from reportlab.lib.units import mm
from reportlab.lib.colors import HexColor, white, black
from reportlab.pdfbase.pdfmetrics import stringWidth
from io import BytesIO
from bson.objectid import ObjectId
import barcode
@@ -4421,19 +4669,20 @@ def student_card_single_barcode_download(card_id):
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
# Korrektur des y_pos auf die OBERKANTE, damit die nachfolgenden Rechnungen bündig sind
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
header_color = HexColor("#0F172A")
accent_color = HexColor("#2563EB")
light_bg = HexColor("#F8FAFC")
card_bg_color = HexColor("#FFFFFF")
text_dark = HexColor("#1E293B")
text_gray = HexColor("#64748B")
# Card shadow effect
c.setFillColor(HexColor("#E2E8F0"))
@@ -4443,7 +4692,7 @@ def student_card_single_barcode_download(card_id):
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)
c.rect(x_pos, y_pos - card_height, card_width, card_height, fill=1, stroke=1)
# Left info section (50mm)
info_width = 50 * mm
@@ -4465,36 +4714,45 @@ def student_card_single_barcode_download(card_id):
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)
school_name_str = str(school_name["name"])
# Zwei-Zeilen-Header
c.setFont("Helvetica-Bold", 10)
c.setFillColor(white)
c.drawString(x_pos + 4*mm, y_pos - 6.5*mm, f"BIBLIOTHEKSAUSWEIS - {str(school_name)}")
c.drawString(x_pos + 4 * mm, y_pos - 4.5 * mm, "BIBLIOTHEKSAUSWEIS")
# Dynamische Größe für den Schulnamen
font_size = 10
while stringWidth(school_name_str, "Helvetica-Bold", font_size) > (card_width - 8 * mm) and font_size > 4:
font_size -= 0.5
c.setFont("Helvetica-Bold", font_size)
c.drawString(x_pos + 4 * mm, y_pos - 8.5 * mm, school_name_str)
# 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)
c.drawString(x_pos + 4 * mm, y_pos - 18 * 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.drawString(x_pos + 4 * mm, y_pos - 24 * 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']))
c.drawString(x_pos + 4 * mm, y_pos - 28 * 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.drawString(x_pos + 4 * mm, y_pos - 34 * mm, "Klasse:")
c.setFillColor(text_dark)
c.setFont("Helvetica-Bold", 11)
c.drawString(x_pos + 4*mm, y_pos - 34*mm, card['Klasse'])
c.drawString(x_pos + 4 * mm, y_pos - 38 * mm, card['Klasse'])
# Barcode section - right side with blue accent
barcode_x_start = x_pos + info_width + 3 * mm
@@ -4503,28 +4761,34 @@ def student_card_single_barcode_download(card_id):
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
# Generate and add large dynamically sized barcode
try:
temp_dir = tempfile.gettempdir()
barcode_path = os.path.join(temp_dir, f"barcode_{card['AusweisId']}")
# Generate barcode with better sizing
# Options anpassen für Scanbarkeit
options = {
'write_text': False,
'quiet_zone': 1.0,
'dpi': 300
}
barcode_obj = barcode.get('code128', str(card['AusweisId']), writer=ImageWriter())
barcode_obj.save(barcode_path)
barcode_obj.save(barcode_path, options=options)
barcode_file = f"{barcode_path}.png"
if os.path.exists(barcode_file):
# Large barcode on right side
# Den gesamten Platz auf der rechten Seite ausnutzen
barcode_width = (card_width - info_width - 8 * mm)
barcode_height = 20*mm
barcode_y_offset = (card_height - 10*mm - barcode_height) / 2
barcode_height = card_height - 18 * mm
barcode_y = y_pos - card_height + 5 * mm
c.drawImage(barcode_file,
barcode_x_start,
y_pos - card_height + barcode_y_offset + 5*mm,
barcode_x_start + 1 * mm,
barcode_y,
width=barcode_width,
height=barcode_height,
preserveAspectRatio=True)
preserveAspectRatio=False) # Ausdehnung in alle Richtungen
os.remove(barcode_file)
else:
raise Exception("Barcode file not created")
@@ -4552,7 +4816,6 @@ def student_card_single_barcode_download(card_id):
flash('Fehler beim PDF-Download', 'error')
return redirect(url_for('student_cards_admin'))
@app.route('/login', methods=['GET', 'POST'])
def login():
"""
@@ -5259,7 +5522,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'
@@ -5603,7 +5866,9 @@ def upload_item():
if item_id:
success_msg = f'Element wurde erfolgreich hinzugefügt ({len(created_item_ids)} erstellt)'
fs = get_gridfs() # Deine GridFS Verbindung
it.cleanup_orphaned_images(fs, dry_run=True)
if upload_mode == 'library':
try:
_append_audit_event_standalone(
@@ -9736,18 +10001,21 @@ def fetch_book_info(isbn):
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
API endpoint to download a book cover image from URL
and save it directly to MongoDB GridFS.
"""
if 'username' not in session:
return jsonify({"error": "Not authorized"}), 403
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['actions'].get('can_insert', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
return redirect(url_for('library_view'))
return jsonify({"error": "Ihnen fehlen die nötigen Berechtigungen."}), 403
if not cfg.MODULES.is_enabled('library'):
return jsonify({"error": "Bibliotheks-Modul ist deaktiviert."}), 403
@@ -9762,17 +10030,11 @@ def download_book_cover():
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']
@@ -9781,16 +10043,14 @@ def download_book_cover():
"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
return jsonify({"error": "Image is too large (max 5MB)"}), 413
except ValueError:
pass
# Generate a fully unique filename
unique_id = str(uuid.uuid4())
timestamp = time.strftime("%Y%m%d%H%M%S")
@@ -9801,23 +10061,29 @@ def download_book_cover():
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:
image_data = io.BytesIO()
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({"error": "Image is too large (max 5MB)"}), 413
image_data.write(chunk)
image_data.seek(0)
fs = get_gridfs()
fs.put(
image_data,
filename=filename,
content_type=content_type
)
return jsonify({
"success": True,
"filename": filename,
"message": "Image downloaded successfully"
"message": "Image downloaded and stored directly in database"
})
except requests.exceptions.RequestException as e:
@@ -9825,8 +10091,8 @@ def download_book_cover():
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
return jsonify({"error": "Failed to download image"}), 500
"""
@app.route('/proxy_image')
def proxy_image():
+134
View File
@@ -25,6 +25,7 @@ import datetime
import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient
import Web.modules.inventarsystem.data_protection as dp
import logging
def is_library_item(item):
@@ -1293,3 +1294,136 @@ def sync_group_codes(primary_obj_id, base_code, individual_codes_list):
except Exception as e:
print(f"Error syncing group codes: {e}")
return False
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
def cleanup_orphaned_images(fs, dry_run=True):
"""
Finds images in GridFS that are no longer referenced by any item
and optionally deletes them.
Supported item fields:
- book_cover_image
- image
- images
The fields are expected to contain GridFS file ObjectIds.
:param fs: GridFS instance, e.g. gridfs.GridFS(db)
:param dry_run: If True, only reports what would be deleted.
If False, actually deletes the files.
"""
logging.info("Starte Cleanup-Skript...")
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
try:
db = client[cfg.MONGODB_DB]
items_collection = db["items"]
referenced_files = set()
for item in items_collection.find(
{},
{
"Images": 1
}
):
images = item.get("Images")
if isinstance(images, list):
for img in images:
if img:
referenced_files.add(img)
referenced_files.discard(None)
logging.info(
f"{len(referenced_files)} referenzierte GridFS-Dateien gefunden."
)
orphaned_files = []
for grid_file in fs.find():
file_id = grid_file._id
filename = grid_file.filename or ""
if not filename.lower().endswith(
(".jpg", ".jpeg", ".png", ".gif", ".webp")
):
continue
if file_id not in referenced_files:
orphaned_files.append(
{
"_id": file_id,
"filename": filename,
"upload_date": grid_file.upload_date,
}
)
logging.info(
f"Gefundene verwaiste Bilder: {len(orphaned_files)}"
)
if dry_run:
logging.info(
"--- DRY RUN AKTIV - Es wird nichts gelöscht ---"
)
for file in orphaned_files:
logging.info(
f"Würde löschen: "
f"{file['filename']} "
f"(ID: {file['_id']}, "
f"Hochgeladen: {file['upload_date']})"
)
logging.info(
"--- Setze dry_run=False, um physisch zu löschen ---"
)
else:
logging.warning("--- LÖSCHVORGANG AKTIV ---")
deleted_count = 0
failed_count = 0
for file in orphaned_files:
try:
fs.delete(file["_id"])
deleted_count += 1
logging.info(
f"Gelöscht: {file['filename']} "
f"(ID: {file['_id']})"
)
except Exception as e:
failed_count += 1
logging.error(
f"Fehler beim Löschen von "
f"{file['filename']} "
f"(ID: {file['_id']}): {e}"
)
logging.info(
f"Cleanup beendet. "
f"{deleted_count} Bilder gelöscht, "
f"{failed_count} Fehler."
)
return {
"status": "success",
"referenced_count": len(referenced_files),
"orphaned_count": len(orphaned_files),
"dry_run": dry_run,
}
finally:
client.close()
+170 -90
View File
@@ -135,21 +135,6 @@
.library-scan-status.warn { color: #9a6700; }
.library-scan-status.error { color: #b42318; }
.library-scan-reader-wrap {
display: none;
margin-top: 12px;
max-width: 460px;
background: var(--ui-surface);
border: 1px solid #d9dde4;
border-radius: 8px;
overflow: hidden;
}
.library-scan-reader {
width: 100%;
min-height: 280px;
}
/* Filters */
.library-filter-toggle-btn {
padding: 10px 16px;
@@ -431,6 +416,71 @@
.library-scan-reader-wrap { max-width: 100%; }
}
.library-scan-reader-wrap {
display: none;
margin-top: 15px;
max-width: 640px;
margin-left: auto;
margin-right: auto;
background: #000;
border: 1px solid #d9dde4;
border-radius: 8px;
overflow: hidden;
grid-column: 1 / -1;
}
.library-scan-reader {
width: 100%;
min-height: 280px;
position: relative;
}
.library-scan-reader video {
width: 100%;
height: auto;
display: block;
object-fit: cover;
}
.library-scan-reader canvas.drawing,
.library-scan-reader canvas.drawingBuffer {
position: absolute;
top: 0;
left: 0;
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 }}">
@@ -457,7 +507,6 @@
🔍 Filter
</button>
</div>
<div class="library-scan-controls">
<select id="scanModeSelect" aria-label="Scan-Modus">
<option value="card_only">Nur Ausweis erfassen</option>
@@ -475,6 +524,10 @@
</label>
<button id="manualActionBtn" class="button" type="button" style="margin-left:6px; background:#4f46e5; color:white;">Code verarbeiten</button>
</div>
<div class="library-scan-reader-wrap" id="scanReaderWrap" style="display: none; margin-top: 15px;">
<div class="library-scan-reader" style="width: 100%; max-width: 640px; margin: 0 auto; overflow: hidden; border-radius: 8px; border: 2px solid #ccc;">
</div>
</div>
<div id="filterPanel" class="library-filter-panel">
<div class="filter-row">
<div class="filter-item">
@@ -805,71 +858,92 @@
deleteLibraryItem(itemId);
}
// =========================================================================
// 3. CORE SCANNER ROUTING ENGINE (QUAGGA2)
// =========================================================================
function startScanner(targetCallback) {
const readerWrap = document.getElementById('scanReaderWrap');
async function startScanner(callback = null) {
if (scannerRunning) {
console.warn("Scanner is already running. Stopping it first...");
await stopScanner();
}
activeScannerCallback = callback;
const scannerWrap = document.querySelector('.library-scan-reader-wrap');
const toggleBtn = document.getElementById('toggleScannerBtn');
activeScannerCallback = targetCallback;
// 1. CRITICAL FIX: Make the container visible BEFORE Quagga initializes
// Quagga cannot calculate video dimensions if display is 'none'
if (scannerWrap) {
scannerWrap.style.display = 'block';
}
if (readerWrap) readerWrap.style.display = 'block';
setScanStatus('Initializing camera...', 'warn');
setScanStatus('Starte Kamera...', 'warn');
// 2. Initialize Quagga with a slight delay to allow the DOM to render the block
setTimeout(() => {
Quagga.init({
inputStream: {
name: "Live",
type: "LiveStream",
target: document.querySelector('#library-scanner-container'),
// This MUST match the inner div where the video should appear
target: document.querySelector('.library-scan-reader'),
constraints: {
width: 640,
height: 480,
facingMode: "environment"
facingMode: "environment" // Prefer back camera on mobile
}
},
locator: {
patchSize: "medium",
halfSample: true
},
decoder: {
readers: [
"code_128_reader",
"ean_reader",
"code_39_reader",
"upc_reader",
"codabar_reader",
"i2of5_reader"
]
}
// Keep only the barcode types you actually use to improve performance
readers: ["code_128_reader", "ean_reader", "code_39_reader"]
},
locate: true
}, function(err) {
if (err) {
console.error('Scanner start failed:', err);
if (readerWrap) readerWrap.style.display = 'none';
const detail = (err && (err.message || err.name)) ? ` (${err.message || err.name})` : '';
setScanStatus(`Scanner konnte nicht gestartet werden${detail}`, 'error');
// This will finally log the actual error (e.g., NotAllowedError) if permissions fail
console.error("Quagga initialization failed:", err);
setScanStatus('Kamera-Fehler: ' + (err.name || err), 'error');
if (scannerWrap) scannerWrap.style.display = 'none';
scannerRunning = false;
return;
}
Quagga.start();
scannerRunning = true;
setScanStatus('Scanner bereit.', 'ok');
if (!targetCallback && toggleBtn) {
if (toggleBtn) {
toggleBtn.textContent = 'Scanner stoppen';
}
setScanStatus('Scanner aktiv. Jetzt Code scannen.', 'warn');
});
}, 50);
}
function stopScanner() {
async function stopScanner() {
if (!scannerRunning) return;
const readerWrap = document.getElementById('scanReaderWrap');
const toggleBtn = document.getElementById('toggleScannerBtn');
try {
Quagga.stop();
} catch (e) {
console.warn("Error stopping Quagga (it may not have been running):", e);
}
scannerRunning = false;
activeScannerCallback = null;
if (readerWrap) readerWrap.style.display = 'none';
if (toggleBtn) toggleBtn.textContent = 'Scanner starten';
setScanStatus('Scanner gestoppt.', 'warn');
// Hide the container to free up UI space
const scannerWrap = document.querySelector('.library-scan-reader-wrap');
if (scannerWrap) {
scannerWrap.style.display = 'none';
}
const toggleBtn = document.getElementById('toggleScannerBtn');
if (toggleBtn) {
toggleBtn.textContent = 'Kamera Scanner'; // Reset button text
}
}
async function isStudentCardBarcode(code) {
@@ -1285,69 +1359,75 @@
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';
const item = libraryItems.find(i => i._id === itemId);
let mediaHtml = '';
// Daten vom Backend-API-Endpoint abrufen
fetch(`/api/item_detail/${itemId}`)
.then(response => {
if (!response.ok) {
throw new Error('Fehler beim Laden der Artikeldetails');
}
return response.json();
})
.then(data => {
detailContent.innerHTML = data.html;
// Robuste Prüfung: Wir testen gängige Benennungen aus deinem Backend
const imageArray = item.Images || item.Bilder || item.images;
const imageArray = data.images || [];
const thumbnailInfoMap = data.thumbnailInfo || [];
if (item && Array.isArray(imageArray) && imageArray.length > 0) {
if (Array.isArray(imageArray) && imageArray.length > 0) {
const imagesHtml = imageArray.map((image, index) => {
// Dein neuer Code für die exakte Routen-Generierung
const imageSrc = image.startsWith('/uploads/') || image.startsWith('http') ?
image :
`{{ url_for('uploaded_file', filename='') }}${image}`;
const thumbnailInfo = item.ThumbnailInfo && item.ThumbnailInfo[index];
const isVideo = isVideoFile(image);
const isVideo = typeof isVideoFile === 'function' ? isVideoFile(image) : /\.(mp4|webm|ogg|mov)$/i.test(image);
const thumbnailInfo = thumbnailInfoMap[index];
if (isVideo) {
const videoSrc = thumbnailInfo && thumbnailInfo.has_thumbnail
? thumbnailInfo.thumbnail_url
: imageSrc;
const videoSrc = image.startsWith('/uploads/') || image.startsWith('http')
? image
: `/uploads/${image}`;
if (thumbnailInfo && thumbnailInfo.has_thumbnail) {
return `
<div class="video-container" style="position: relative; width: 120px; height: 120px; display: inline-block; margin-right: 15px; margin-bottom: 15px;">
<img src="${videoSrc}" alt="${escapeHtml(item.Name || 'Medium')}" class="item-image" style="width: 100%; height: 100%; object-fit: cover; border-radius: 8px; border: 1px solid #ddd;">
<div class="video-preview-overlay" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: white; background: rgba(0,0,0,0.6); border-radius: 50%; width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; font-size: 16px;">
▶
</div>
<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 {
return `<div style="width: 120px; height: 120px; background: #333; color: #fff; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; margin-right: 15px; margin-bottom: 15px;">VIDEO</div>`;
}
} else {
const imageSrcFinal = thumbnailInfo && thumbnailInfo.has_thumbnail
? thumbnailInfo.thumbnail_url
: imageSrc;
const imageSrc = thumbnailInfo && thumbnailInfo.has_preview
? thumbnailInfo.preview_url
: (image.startsWith('/uploads/') || image.startsWith('http')
? image
: `/uploads/${image}`);
return `<img src="${imageSrcFinal}" 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;">`;
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('');
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>`;
}
const mediaHtml = `<div class="detail-gallery-container">${imagesHtml}</div>`;
fetch(`/api/item_detail/${itemId}`)
.then(response => {
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.text();
const h2Tag = detailContent.querySelector('h2');
if (h2Tag) {
h2Tag.insertAdjacentHTML('afterend', mediaHtml);
} else {
detailContent.insertAdjacentHTML('afterbegin', mediaHtml);
}
}
})
.then(html => {
detailContent.innerHTML = mediaHtml + DOMPurify.sanitize(html);
})
.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>';
});
}
// Closes the modal via the 'x' button
// Schließt das Modal über den 'x'-Button
function closeDetailModal() {
document.getElementById('detailModal').style.display = 'none';
}
+21 -5
View File
@@ -232,6 +232,16 @@
<h1>📚 Bibliotheksausweise (Bibliothek)</h1>
</div>
<div class="export-buttons">
<form method="GET" action="{{ url_for('student_card_class_barcode_download') }}" style="display: inline-flex; gap: 5px; align-items: center; background: white; padding: 2px; border-radius: 4px; border: 1px solid #ddd;">
<select name="class_name" required style="border: none; padding: 8px; outline: none; font-size: 14px; background: transparent; cursor: pointer;">
<option value="" disabled selected>-- Klasse wählen --</option>
{% for cls in available_classes %}
<option value="{{ cls }}">{{ cls }}</option>
{% endfor %}
</select>
<button type="submit" class="btn-print" style="background: #17a2b8; padding: 8px 12px; margin: 0;">📤 PDF</button>
</form>
<a href="{{ url_for('student_card_barcode_download') }}" class="btn-print" style="background: #28a745;">📥 Alle Ausweise (PDF)</a>
<a href="{{ url_for('library_admin') }}" class="btn btn-primary">← Zur Bibliotheks-Upload</a>
</div>
@@ -239,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>
@@ -261,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>
@@ -283,9 +293,15 @@
</div>
<div class="form-group">
<label for="class_name">Klasse</label>
<input type="text" id="class_name" name="class_name"
<input type="text" id="class_name" name="class_name" list="class_list"
value="{{ form_data.get('class_name', '') }}"
placeholder="z.B. 10A">
placeholder="z.B. 10A (Tippen oder Auswählen)">
<datalist id="class_list">
{% for cls in available_classes %}
<option value="{{ cls }}">
{% endfor %}
</datalist>
</div>
</div>
+42 -55
View File
@@ -876,12 +876,14 @@
</div>
<!-- Image upload -->
<div class="form-group">
<label for="images">Bilder:</label>
<label>Buchcover (automatisch):</label>
<div id="book-cover-preview-container"></div>
</div>
<div class="form-group">
<label for="images"> Bilder hinzufügen:</label>
<input type="file" id="images" name="images" accept=".jpg, .jpeg, .png, .gif" multiple>
<div class="allowed-formats">Erlaubte Formate: JPG, JPEG, PNG, GIF</div>
<!-- Add image preview area -->
<div class="image-preview-container" id="image-preview-container"></div>
</div>
@@ -1669,23 +1671,24 @@
}, 3000);
}
// Function to download book cover image
function downloadBookCover(imageUrl) {
if (!imageUrl) {
console.log('No image URL provided');
return;
}
// Show loading indicator for image download
const imagePreviewContainer = document.getElementById('image-preview-container');
if (imagePreviewContainer) {
const coverPreviewContainer = document.getElementById('book-cover-preview-container');
if (!coverPreviewContainer) {
console.error('Error: "book-cover-preview-container" not found in the DOM.');
return;
}
const loadingDiv = document.createElement('div');
loadingDiv.className = 'image-loading';
loadingDiv.innerHTML = '<div class="loading-spinner">Buchcover wird heruntergeladen...</div>';
imagePreviewContainer.appendChild(loadingDiv);
}
coverPreviewContainer.appendChild(loadingDiv);
// Download the image via backend
fetch('/download_book_cover', {
method: 'POST',
headers: {
@@ -1695,76 +1698,61 @@
})
.then(response => response.json())
.then(data => {
// Remove loading indicator
const loadingDiv = imagePreviewContainer?.querySelector('.image-loading');
if (loadingDiv) {
loadingDiv.remove();
const currentLoadingDiv = coverPreviewContainer.querySelector('.image-loading');
if (currentLoadingDiv) {
currentLoadingDiv.remove();
}
if (data.success) {
// Create a preview of the downloaded image
coverPreviewContainer.innerHTML = '';
const imagePreview = document.createElement('div');
imagePreview.className = 'book-cover-preview';
imagePreview.innerHTML = `
<div class="preview-item">
<img src="{{ url_for('uploaded_file', filename='') }}${data.filename}"
alt="Buchcover" class="book-cover-thumbnail">
<p class="book-cover-caption">Buchcover automatisch heruntergeladen</p>
<img src="/uploads/${data.filename}"
alt="Buchcover" class="book-cover-thumbnail" style="max-width: 150px; border-radius: 4px;">
<p class="book-cover-caption" style="font-size: 0.9em; color: #555;">Buchcover automatisch heruntergeladen</p>
<input type="hidden" name="book_cover_image" value="${data.filename}">
<button type="button" onclick="removeBookCover(this)"
class="remove-book-cover-button">
class="remove-book-cover-button btn btn-sm btn-danger">
Entfernen
</button>
</div>
`;
if (imagePreviewContainer) {
imagePreviewContainer.appendChild(imagePreview);
}
coverPreviewContainer.appendChild(imagePreview);
console.log('Book cover downloaded successfully:', data.filename);
} else {
console.error('Failed to download book cover:', data.error);
// Show error message to user
if (imagePreviewContainer) {
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message';
errorDiv.textContent = 'Fehler beim Herunterladen des Buchcovers: ' + data.error;
errorDiv.style.fontSize = '0.8em';
errorDiv.style.padding = '5px';
errorDiv.style.marginTop = '5px';
imagePreviewContainer.appendChild(errorDiv);
// Remove error message after 5 seconds
setTimeout(() => errorDiv.remove(), 5000);
}
showCoverError(coverPreviewContainer, 'Fehler beim Herunterladen des Buchcovers: ' + data.error);
}
})
.catch(error => {
console.error('Error downloading book cover:', error);
// Remove loading indicator
const loadingDiv = imagePreviewContainer?.querySelector('.image-loading');
if (loadingDiv) {
loadingDiv.remove();
const currentLoadingDiv = coverPreviewContainer.querySelector('.image-loading');
if (currentLoadingDiv) {
currentLoadingDiv.remove();
}
// Show error message
if (imagePreviewContainer) {
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message';
errorDiv.textContent = 'Netzwerkfehler beim Herunterladen des Buchcovers';
errorDiv.style.fontSize = '0.8em';
errorDiv.style.padding = '5px';
errorDiv.style.marginTop = '5px';
imagePreviewContainer.appendChild(errorDiv);
// Remove error message after 5 seconds
setTimeout(() => errorDiv.remove(), 5000);
}
showCoverError(coverPreviewContainer, 'Netzwerkfehler beim Herunterladen des Buchcovers');
});
}
// Function to remove downloaded book cover
function showCoverError(container, message) {
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message';
errorDiv.textContent = message;
errorDiv.style.fontSize = '0.8em';
errorDiv.style.color = 'red';
errorDiv.style.padding = '5px';
errorDiv.style.marginTop = '5px';
container.appendChild(errorDiv);
setTimeout(() => errorDiv.remove(), 5000);
}
function removeBookCover(button) {
const previewItem = button.closest('.preview-item');
if (previewItem) {
@@ -1772,7 +1760,6 @@
}
}
// Code validation functions
function checkCodeUnique(code, excludeId, callback) {
if (!code || code.trim() === '') {
callback(true);