')
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[:3]:
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"{html.escape(str(user or '-'))} — "
f"{html.escape(str(status))} — "
f"{html.escape(str(start))} → {html.escape(str(end))}"
f"{(' — ' + notes) if notes else ''}"
)
borrows_html = f"Ausleihhistorie
"
detail_html = f"""
{html.escape(str(item.get('Name', 'Untitled')))}
ISBN: {html.escape(str(item.get('ISBN', item.get('Code4', '-'))))}
Anzahl: {html.escape(str(item.get('SeriesCount', '-')))}
Code: {html.escape(str(item.get('Code_4', '-')))}
Ort: {html.escape(str(item.get('Ort', '-')))}
Typ: {html.escape(str(item.get('ItemType', '-')))}
Kategorie: {html.escape(str(item.get('library_category', '-')))}
Beschreibung: {html.escape(str(item.get('Beschreibung', '-')))}
Status: {html.escape(str(status_label))}
{f'Ausgeliehen von: {html.escape(str(borrower_value))}
' 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', [])),
'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
@app.route('/api/library_item//update', methods=['POST'])
def api_library_item_update(item_id):
"""Admin-only API to edit library item core fields from the library table view."""
if 'username' not in session:
return jsonify({'ok': False, 'message': 'Nicht angemeldet.'}), 401
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['actions'].get('can_edit', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
return redirect(url_for('library_view'))
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.
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'))
return render_template(
'upload_admin.html',
username=session['username'],
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'))
return render_template(
'upload_admin.html',
username=session['username'],
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'
)
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():
"""
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'))
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'))
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_ident': card.get('ausweis_ident', ''),
'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()
ausweis_ident = request.form.get('ausweis_ident', '').strip()
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')
if not ausweis_ident:
ausweis_ident = ausweis_id
else:
encrypted_payload = encrypt_document_fields(
{
'ausweis_ident': ausweis_ident,
'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 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 not ausweis_ident:
ausweis_ident = ausweis_id
if existing:
flash('Diese ID existiert bereits.', 'error')
else:
try:
encrypted_payload = encrypt_document_fields(
{
'ausweis_ident': ausweis_ident,
'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 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',
student_cards=all_cards, # Hier all_cards übergeben
edit_mode=edit_mode,
form_data=form_data,
available_classes=available_classes, # Enthält nun die lesbaren Klassen
config=cfg.get_school_info()
)
@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'))
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'))
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'))
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'))
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'))
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'))
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 reportlab.pdfbase.pdfmetrics import stringWidth
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_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 - 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 - 16 * mm, name)
# ID with label
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['AusweisId']))
# Class with label
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'])
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
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 dynamically sized barcode
try:
temp_dir = tempfile.gettempdir()
barcode_path = os.path.join(temp_dir, f"barcode_{card['AusweisId']}")
# 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, options=options)
barcode_file = f"{barcode_path}.png"
if os.path.exists(barcode_file):
# Barcode über den fast kompletten restlichen Bereich strecken
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) # Strecken erlauben für maximalen Scanbereich
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_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/', 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'))
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'))
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 reportlab.pdfbase.pdfmetrics import stringWidth
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
card_width = 105 * mm
card_height = 70 * mm
x_pos = (page_width - card_width) / 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")
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"))
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_height, 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_str = str(school_name["name"])
# Zwei-Zeilen-Header
c.setFont("Helvetica-Bold", 10)
c.setFillColor(white)
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 - 18 * mm, name)
# ID section with label
c.setFont("Helvetica", 9)
c.setFillColor(text_gray)
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 - 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 - 34 * mm, "Klasse:")
c.setFillColor(text_dark)
c.setFont("Helvetica-Bold", 11)
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
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 dynamically sized barcode
try:
temp_dir = tempfile.gettempdir()
barcode_path = os.path.join(temp_dir, f"barcode_{card['AusweisId']}")
# 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, options=options)
barcode_file = f"{barcode_path}.png"
if os.path.exists(barcode_file):
# Den gesamten Platz auf der rechten Seite ausnutzen
barcode_width = (card_width - info_width - 8 * mm)
barcode_height = card_height - 18 * mm
barcode_y = y_pos - card_height + 5 * mm
c.drawImage(barcode_file,
barcode_x_start + 1 * mm,
barcode_y,
width=barcode_width,
height=barcode_height,
preserveAspectRatio=False) # Ausdehnung in alle Richtungen
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_admin'))
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_admin'))
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_admin'))
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/')
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:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
ausleihungen = db['ausleihungen']
query = {'ConflictDetected': True, 'Status': 'active'}
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['actions'].get('can_view_logs', False):
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/', methods=['POST'])
def add_fav(item_id):
_ensure_session_favs()
if item_id not in session['favorites']:
session['favorites'].append(item_id)
username = session.get('username')
if username:
try:
us.add_favorite(username, item_id)
except Exception as e:
app.logger.warning(f"Persist add favorite failed: {e}")
session.modified = True
return jsonify({'ok': True, 'favorites': session['favorites']})
@app.route('/favorites/', methods=['DELETE'])
def remove_fav(item_id):
_ensure_session_favs()
session['favorites'] = [f for f in session['favorites'] if f != item_id]
username = session.get('username')
if username:
try:
us.remove_favorite(username, item_id)
except Exception as e:
app.logger.warning(f"Persist remove favorite failed: {e}")
session.modified = True
return jsonify({'ok': True, 'favorites': session['favorites']})
@app.route('/favorites/toggle/', methods=['POST'])
def toggle_fav(item_id):
_ensure_session_favs()
session_favs = [str(f) for f in session.get('favorites', [])]
item_id = str(item_id)
is_favorite = item_id in session_favs
username = session.get('username')
if is_favorite:
session_favs = [f for f in session_favs if f != item_id]
if username:
try:
us.remove_favorite(username, item_id)
except Exception as e:
app.logger.warning(f"Persist toggle(remove) favorite failed: {e}")
else:
session_favs.append(item_id)
if username:
try:
us.add_favorite(username, item_id)
except Exception as e:
app.logger.warning(f"Persist toggle(add) favorite failed: {e}")
# Normalize and de-duplicate while preserving order.
deduped = []
seen = set()
for fav in session_favs:
if fav not in seen:
seen.add(fav)
deduped.append(fav)
session['favorites'] = deduped
session.modified = True
return jsonify({
'ok': True,
'is_favorite': item_id in deduped,
'favorites': deduped
})
@app.route('/debug/favorites')
def debug_favorites():
"""Diagnostic endpoint: shows session favorites, DB favorites and merged output."""
_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 image file uploads and creates QR codes.
Enhanced for mobile browser compatibility.
Returns:
flask.Response: Redirect to admin homepage
"""
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
fs = get_gridfs()
if cfg.MODULES.is_enabled('library') and sanitize_form_value(request.form.get('item_type_input', '')) != "other":
success_redirect_endpoint = 'library_view'
else:
success_redirect_endpoint = 'home_admin'
# Detect if request is from mobile device
is_mobile = 'Mobile' in request.headers.get('User-Agent', '')
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
individual_codes = []
if individual_codes_raw:
individual_codes = [c.strip() for c in str(individual_codes_raw).replace(',', '\n').splitlines() if
c.strip()]
# Get book cover image if downloaded
book_cover_image = request.form.get('book_cover_image')
# Special handling for mobile browsers
if is_mobile and 'mobile_data' in request.form:
try:
mobile_data = json.loads(request.form['mobile_data'])
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', [])
except json.JSONDecodeError as e:
app.logger.error(f"Error parsing mobile data: {str(e)}")
except Exception as e:
app.logger.error(f"Upload Form Data Parsing Error: {str(e)}")
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)
# Base 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'))
# Strict Image Validation (Since duplication is removed)
if upload_mode != 'library' and not 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))
primary_code_raw = request.form.get('code_4', '').strip()
all_item_codes = []
if primary_code_raw:
all_item_codes.append(primary_code_raw)
if individual_codes:
for c in individual_codes:
if c not in all_item_codes:
all_item_codes.append(c)
if len(all_item_codes) > 0:
item_count = len(all_item_codes)
# Check unique codes
for code in all_item_codes:
if not it.is_code_unique(code):
app.logger.info(f"DEBUG: Code '{code}' is not unique.")
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'])))
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))
def generate_unique_batch_code(base_code, position):
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
image_filenames = []
processed_count = 0
error_count = 0
skipped_count = 0
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)}")
# 1. Process Manual Image Uploads
for index, image in enumerate(images):
if not image or not image.filename:
skipped_count += 1
continue
image_log_prefix = f"[Upload {upload_session_id}][Image {index + 1}/{len(images)}]"
app.logger.info(f"{image_log_prefix} Processing: {image.filename}")
try:
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}")
skipped_count += 1
if not is_mobile:
flash(error_message, 'error')
continue
secure_name = secure_filename(image.filename)
image.seek(0)
image_bytes = image.read()
if not image_bytes:
app.logger.error(f"{image_log_prefix} Failed to read image (0 bytes).")
error_count += 1
continue
optimized_io = io.BytesIO()
with Image.open(io.BytesIO(image_bytes)) as img:
if img.mode not in ('RGB', 'RGBA'):
img = img.convert('RGBA')
max_width = 500
if img.width > max_width:
ratio = max_width / img.width
new_size = (max_width, int(img.height * ratio))
img = img.resize(new_size, Image.Resampling.LANCZOS)
img.save(optimized_io, format='WEBP', quality=85, optimize=True)
optimized_io.seek(0)
new_filename = f"{uuid.uuid4().hex}_{int(time.time())}.webp"
file_id = fs.put(
optimized_io,
filename=new_filename,
content_type='image/webp',
metadata={
'original_filename': secure_name,
'upload_session': upload_session_id
}
)
image_filenames.append(new_filename)
processed_count += 1
final_size_kb = len(optimized_io.getvalue()) / 1024
app.logger.info(
f"{image_log_prefix} Saved to GridFS as {new_filename} | ID: {file_id} | Size: {final_size_kb:.1f}KB")
except Exception as e:
app.logger.error(f"{image_log_prefix} Processing failed: {str(e)}")
error_count += 1
# 2. Process Book Cover
if book_cover_image:
new_filename = f"{uuid.uuid4().hex}_{int(time.time())}_book_cover.webp"
local_path = os.path.join(app.config.get('UPLOAD_FOLDER', ''), book_cover_image)
try:
if os.path.exists(local_path):
with Image.open(local_path) as img:
if img.mode not in ('RGB', 'RGBA'):
img = img.convert('RGBA')
optimized_io = io.BytesIO()
img.save(optimized_io, format='WEBP', quality=85, optimize=True)
optimized_io.seek(0)
fs.put(
optimized_io,
filename=new_filename,
content_type='image/webp',
metadata={'is_book_cover': True}
)
image_filenames.append(new_filename)
app.logger.info(f"Processed local book cover and saved to GridFS: {new_filename}")
else:
existing_cover = fs.find_one({"filename": book_cover_image})
if existing_cover:
fs.put(
existing_cover.read(),
filename=new_filename,
content_type=existing_cover.content_type,
metadata={'is_book_cover': True}
)
image_filenames.append(new_filename)
app.logger.info(f"Duplicated existing GridFS book cover: {new_filename}")
else:
app.logger.warning(f"Book cover not found locally or in GridFS: {book_cover_image}")
except Exception as e:
app.logger.error(f"Error processing book cover {book_cover_image}: {str(e)}")
app.logger.info(
f"Upload session {upload_session_id} completed: {processed_count} processed, {error_count} errors, {skipped_count} skipped")
# 3. Item Creation
predefined_locations = it.get_predefined_locations()
if ort and ort not in predefined_locations:
it.add_predefined_location(ort)
reservierbar = 'reservierbar' in request.form
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:
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(
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 as e:
app.logger.warning(f"Audit write failed for library_item_created: {str(e)}")
flash(success_msg, 'success')
return redirect(url_for(success_redirect_endpoint))
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))
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/', 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('Nicht angemeldet.', 'error')
return redirect(url_for('login'))
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['actions'].get('can_delete', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion (Löschen) auszuführen.', 'error')
return redirect(url_for('home_admin'))
if not cfg.MODULES.is_enabled('inventory'):
flash('Bibliotheks-Modul ist deaktiviert.', 'error')
return redirect(url_for('library_view'))
# 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/', 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'))
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['actions'].get('can_delete', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion (Löschen) auszuführen.', 'error')
return redirect(url_for('home_admin'))
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:
flash('Nicht angemeldet.', 'error')
return redirect(url_for('login'))
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['actions'].get('can_delete', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion (Löschen) auszuführen.', 'error')
return redirect(url_for('home_admin'))
if not cfg.MODULES.is_enabled('inventory'):
flash('Bibliotheks-Modul ist deaktiviert.', 'error')
return redirect(url_for('library_view'))
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('/item_edit/', methods=['GET', 'POST'])
def item_edit(id):
if 'username' not in session:
if request.method == 'POST' and request.is_json:
return jsonify({'success': False, 'message': 'Nicht angemeldet.'}), 401
flash('Bitte melden Sie sich an.', 'error')
return redirect(url_for('login'))
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['actions'].get('can_edit', False):
if request.method == 'POST' and request.is_json:
return jsonify({'success': False, 'message': 'Keine Berechtigung zum Bearbeiten.'}), 403
flash('Keine Berechtigung zum Bearbeiten.', 'error')
return redirect(url_for('home_admin'))
try:
obj_id = ObjectId(id)
except InvalidId:
flash('Ungültige Element-ID.', 'error')
return redirect(url_for('home_admin'))
current_item = it.get_item(obj_id)
if not current_item:
flash('Element in der Datenbank nicht gefunden.', 'error')
return redirect(url_for('home_admin'))
# Bibliothek-Status ermitteln
library_module_active = cfg.MODULES.is_enabled('library')
is_lib_item = it.is_library_item(current_item)
show_library_features = library_module_active and is_lib_item
# -------------------------------------------------------------------
# GET METHOD
# -------------------------------------------------------------------
if request.method == 'GET':
current_item['_id'] = str(current_item['_id'])
base_code = current_item.get('Code_4', '')
individual_codes = []
if current_item.get('SeriesGroupId'):
group_ids = it.get_group_item_ids(str(current_item['_id']))
if group_ids:
for gid in group_ids:
g_item = it.get_item(gid)
c4 = g_item.get('Code_4')
if c4 and c4 != base_code:
individual_codes.append(c4)
current_item['IndividualCodes'] = '\n'.join(individual_codes)
return render_template(
'edit_library.html',
username=session['username'],
item=current_item,
show_library_features=show_library_features,
library_module_enabled=library_module_active,
page_title=f"Bearbeiten: {current_item.get('Name', '')}"
)
# -------------------------------------------------------------------
# POST METHOD
# -------------------------------------------------------------------
redirect_target = request.referrer or url_for('home_admin')
name = sanitize_form_value(request.form.get('name'))
ort = sanitize_form_value(request.form.get('ort'))
beschreibung = sanitize_form_value(request.form.get('beschreibung'))
anschaffungs_jahr = sanitize_form_value(request.form.get('anschaffungsjahr'))
anschaffungs_kosten = sanitize_form_value(request.form.get('anschaffungskosten'))
reservierbar = 'reservierbar' in request.form
code_4 = sanitize_form_value(request.form.get('code_4'))
individual_codes_raw = request.form.get('individual_codes', '')
individual_codes = []
for c in individual_codes_raw.replace('\r', '').split('\n'):
clean_c = sanitize_form_value(c)
if clean_c and clean_c != code_4 and clean_c not in individual_codes:
individual_codes.append(clean_c)
all_codes_to_check = [code_4] + individual_codes
current_group_id = current_item.get('SeriesGroupId')
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db_instance = client[cfg.MONGODB_DB]
items_col = db_instance['items']
has_code_error = False
for code in all_codes_to_check:
if not code:
continue
existing = items_col.find_one({'Code_4': code, 'Deleted': {'$ne': True}})
if existing:
is_same_item = str(existing['_id']) == str(id)
is_in_same_group = current_group_id and existing.get('SeriesGroupId') == current_group_id
if not is_same_item and not is_in_same_group:
flash(f'Der Code "{code}" wird bereits von einem anderen Artikel verwendet.', 'error')
has_code_error = True
break
client.close()
if has_code_error:
return redirect(redirect_target)
if show_library_features:
# LIBRARY ITEM: Process ISBN/Medientyp/Category, preserve existing filters
isbn_raw = sanitize_form_value(request.form.get('isbn', ''))
item_isbn = normalize_and_validate_isbn(isbn_raw) if isbn_raw else ''
item_type = sanitize_form_value(request.form.get('item_type_input', current_item.get('ItemType', 'Buch')))
library_category = sanitize_form_value(request.form.get('library_category', ''))
images = current_item.get('Images', [])
filter1 = current_item.get('Filter', [])
filter2 = current_item.get('Filter2', [])
filter3 = current_item.get('Filter3', [])
else:
# NON-LIBRARY (INVENTORY) ITEM: Process Filter 1-3 from form
item_isbn = current_item.get('ISBN', '')
item_type = 'other'
library_category = current_item.get('library_category', '')
filter1 = expand_filter_selection(sanitize_form_value(request.form.getlist('filter')), 1)
filter2 = expand_filter_selection(sanitize_form_value(request.form.getlist('filter2')), 2)
filter3 = sanitize_form_value(request.form.getlist('filter3'))
images_to_keep = request.form.getlist('existing_images')
original_images = current_item.get('Images', [])
images = [img for img in original_images if img in images_to_keep]
new_files = request.files.getlist('images')
if new_files and new_files[0].filename != '':
fs = get_gridfs()
for file in new_files:
if file and file.filename:
is_allowed, error_msg = allowed_file(file.filename, file)
if not is_allowed:
flash(error_msg, 'error')
return redirect(redirect_target)
try:
secure_name = secure_filename(file.filename)
file.seek(0)
image_bytes = file.read()
if not image_bytes:
continue
optimized_io = io.BytesIO()
with Image.open(io.BytesIO(image_bytes)) as img:
if img.mode not in ('RGB', 'RGBA'):
img = img.convert('RGBA')
max_width = 800
if img.width > max_width:
ratio = max_width / img.width
img = img.resize((max_width, int(img.height * ratio)), Image.Resampling.LANCZOS)
img.save(optimized_io, format='WEBP', quality=85, optimize=True)
optimized_io.seek(0)
new_filename = f"{uuid.uuid4().hex}_{int(time.time())}.webp"
fs.put(
optimized_io,
filename=new_filename,
content_type='image/webp',
metadata={'original_filename': secure_name, 'item_id': str(id)}
)
images.append(new_filename)
except Exception as e:
app.logger.error(f"Image error for item {id}: {e}")
if ort and ort not in it.get_predefined_locations():
it.add_predefined_location(ort)
it.sync_group_codes(str(id), code_4, individual_codes)
success = it.update_item(
id=str(id),
name=name,
ort=ort,
beschreibung=beschreibung,
images=images,
verfuegbar=current_item.get('Verfuegbar', True),
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,
library_category=library_category
)
if success:
flash('Artikel erfolgreich aktualisiert.', 'success')
else:
flash('Fehler beim Aktualisieren des Artikels.', 'error')
return redirect(redirect_target)
@app.route('/update_group', methods=['POST'])
def update_group():
if 'username' not in session:
flash('Nicht angemeldet.', 'error')
return redirect(url_for('login'))
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['actions'].get('can_edit', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion (Löschen) auszuführen.', 'error')
return redirect(url_for('library_view'))
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 = {'LastUpdated': datetime.datetime.now()}
for source_key, target_key in (
('name', 'Name'),
('ort', 'Ort'),
('beschreibung', 'Beschreibung'),
('ansch_jahr', 'Anschaffungsjahr'),
('ansch_kost', 'Anschaffungskosten'),
('reservierbar', 'Reservierbar'),
('isbn', 'ISBN'),
('item_type', 'ItemType'),
):
value = data.get(source_key)
if value is not None:
shared_update[target_key] = value
# 2. Individual Updates (Specific Code Logic)
# Expected format: [{'id': '...', 'code_4': '...'}, ...]
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}
)
app.logger.debug(f"Individual Codes: {individual_items}")
# 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()
app.logger.debug("Success When Updating the Item")
flash("Objekte wurden erfolgreich Bearbeitet", "success")
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/', 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
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/', 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
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['actions'].get('can_edit', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion (Löschen) auszuführen.', '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_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
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/', 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'
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.
current_permissions = us.get_effective_permissions(session['username'])
if current_permissions['actions'].get('can_borrow') and not is_library_item:
if student_card_id:
student_user = us.get_user_by_student_card(student_card_id)
app.logger.debug(f"Borrowing on behalf of student card {student_card_id}: found user {student_user}")
if not student_user:
flash('Keine Schülerin/kein Schüler mit dieser Ausweis-ID gefunden.', 'error')
return redirect(url_for('library_view'))
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/', 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_admin'))
username = session['username']
current_permissions = us.get_effective_permissions(session['username'])
if not item.get('Verfuegbar', True) and (current_permissions['actions'].get('can_manage_users', False)) 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'))
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/')
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
current_permissions = us.get_effective_permissions(session['username'])
if ausleihung and (current_permissions['actions'].get('can_manage_users')) or ausleihung.get('User') == session['username']:
return {'ausleihung': ausleihung, 'status': 'success'}
item = it.get_item(id)
item_name = item.get('Name', 'Unknown') if item else 'Unknown'
return {
'error': 'No active borrowing record found for this item',
'item_name': item_name,
'status': 'not_found'
}, 200
@app.route('/get_planned_bookings/')
def get_planned_bookings(item_id):
"""
Return all planned bookings for a given item (admin only).
"""
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['pages'].get('admin_borrowings', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
return redirect(url_for('home_admin'))
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/')
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
@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/', 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
current_permissions = us.get_effective_permissions(session['username'])
# Check if user owns this booking
if booking.get('User') != session['username'] and not current_permissions['actions'].get('can_manage_users', False):
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_admin'))
# 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_admin'))
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'))
'''-------------------------------------------------------------------------------------------------------------ADMIN ROUTES------------------------------------------------------------------------------------------------------------------'''
@app.route('/register', methods=['GET', 'POST'])
def register():
"""
User registration route.
Returns:
flask.Response: Rendered template or redirect
"""
if 'username' not in session:
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adresse zu nutzen, versuchen Sie es erneut, nachdem Sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
return redirect(url_for('login'))
if request.method == 'POST':
password = request.form['password']
name = (request.form.get('name') or '').strip()
last_name = (request.form.get('last-name') or '').strip()
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 oder entspricht nicht den Richtlinien', '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,
)
flash(f'Benutzer "{username}" wurde erfolgreich registriert!', 'success')
return redirect(url_for('home_admin'))
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,
permission_presets=getattr(us, 'PERMISSION_PRESETS', {}),
permission_action_options=PERMISSION_ACTION_OPTIONS,
permission_page_options=PERMISSION_PAGE_OPTIONS
)
def parse_csv_users(file_bytes):
"""
Liest CSV-Dateien extrem robust ein:
- Erkennt automatisch UTF-8, UTF-8-SIG und Latin-1 (für Excel-Umlaute).
- Erkennt automatisch das Trennzeichen (; oder , oder Tab).
- Erkennt Vor- und Nachname unabhängig von der Spaltenreihenfolge und Headernamen.
"""
# 1. Dekodierung mit Fallback für deutsche Excel-Dateien
try:
content = file_bytes.decode('utf-8-sig')
except UnicodeDecodeError:
content = file_bytes.decode('latin-1')
lines = [line.strip() for line in content.splitlines() if line.strip()]
if not lines:
return []
# 2. Trennzeichen ermitteln (; , oder \t)
first_line = lines[0]
if first_line.count(';') >= first_line.count(','):
delimiter = ';'
elif first_line.count('\t') > first_line.count(','):
delimiter = '\t'
else:
delimiter = ','
stream = io.StringIO(content, newline=None)
reader = csv.DictReader(stream, delimiter=delimiter)
parsed_users = []
# 3. Auswertung mit DictReader (wenn Header vorhanden sind)
if reader.fieldnames:
for row in reader:
# Keys normalisieren (kleingeschrieben, ohne Leerzeichen)
cleaned_row = {str(k).strip().lower(): str(v).strip() for k, v in row.items() if k and v}
# Dynamisches Mapping für Vornamen
name = (
cleaned_row.get('vorname') or
cleaned_row.get('first_name') or
cleaned_row.get('firstname') or
cleaned_row.get('name') or ''
)
# Dynamisches Mapping für Nachnamen
last_name = (
cleaned_row.get('nachname') or
cleaned_row.get('last_name') or
cleaned_row.get('lastname') or
cleaned_row.get('surname') or
cleaned_row.get('familienname') or ''
)
# Falls Header nicht erkannt wurden, aber Werte vorhanden sind (z.B. CSV ohne Header)
if not name and not last_name:
vals = [str(v).strip() for v in row.values() if v]
if len(vals) >= 2:
name, last_name = vals[0], vals[1]
if name or last_name:
parsed_users.append({'name': name, 'last_name': last_name})
else:
# Fallback für dateien ohne Header
stream.seek(0)
raw_reader = csv.reader(stream, delimiter=delimiter)
for row in raw_reader:
clean_row = [str(cell).strip() for cell in row if str(cell).strip()]
if len(clean_row) >= 2:
# Header-Zeilen überspringen
if clean_row[0].lower() in ['vorname', 'first_name', 'name'] and clean_row[1].lower() in ['nachname', 'last_name']:
continue
parsed_users.append({'name': clean_row[0], 'last_name': clean_row[1]})
return parsed_users
def generate_compliant_password(length=16):
lowers = string.ascii_lowercase
uppers = string.ascii_uppercase
digits = string.digits
symbols = "!@#$%^&*()_+~|}{[]:;?><,.-="
# Ensure at least one character from each required category
pwd = [
secrets.choice(lowers),
secrets.choice(uppers),
secrets.choice(digits),
secrets.choice(symbols)
]
all_chars = lowers + uppers + digits + symbols
pwd += [secrets.choice(all_chars) for _ in range(length - 4)]
# Shuffle so guaranteed types aren't always at the start
secrets.SystemRandom().shuffle(pwd)
return "".join(pwd)
def generate_credentials_pdf(created_users):
"""
Creates a PDF in memory with 2 user credential cards per A4 page.
created_users: list of dicts [{'name': ..., 'last_name': ..., 'username': ..., 'password': ...}]
"""
buffer = io.BytesIO()
pdf = canvas.Canvas(buffer, pagesize=A4)
width, height = A4 # 595.27 x 841.89 points
card_height = height / 2.0 # Split page into 2 equal halves
for i, user in enumerate(created_users):
page_slot = i % 2 # 0 = Top half, 1 = Bottom half
# If starting a new page (except for the very first item)
if i > 0 and page_slot == 0:
pdf.showPage()
# Calculate Y offset for card position
y_offset = height - (page_slot + 1) * card_height
# Card Container Box
margin = 35
box_x = margin
box_y = y_offset + margin
box_w = width - (2 * margin)
box_h = card_height - (2 * margin)
# Outer Border
pdf.setStrokeColor(colors.HexColor('#CBD5E1'))
pdf.setLineWidth(1)
pdf.rect(box_x, box_y, box_w, box_h, fill=0)
# Header Banner inside Card
pdf.setFillColor(colors.HexColor('#1E293B'))
pdf.rect(box_x, box_y + box_h - 45, box_w, 45, fill=1, stroke=0)
# Header Title Text
pdf.setFillColor(colors.white)
pdf.setFont("Helvetica-Bold", 14)
pdf.drawString(box_x + 20, box_y + box_h - 28, "Zugangsdaten / Account Credentials")
# User Info Details
content_y = box_y + box_h - 80
# Name
pdf.setFillColor(colors.HexColor('#0F172A'))
pdf.setFont("Helvetica-Bold", 12)
pdf.drawString(box_x + 25, content_y, f"Name: {user['name']} {user['last_name']}")
# Username
content_y -= 35
pdf.setFont("Helvetica", 11)
pdf.setFillColor(colors.HexColor('#475569'))
pdf.drawString(box_x + 25, content_y, "Benutzername:")
pdf.setFont("Helvetica-Bold", 13)
pdf.setFillColor(colors.HexColor('#0F172A'))
pdf.drawString(box_x + 160, content_y, user['username'])
# Password
content_y -= 30
pdf.setFont("Helvetica", 11)
pdf.setFillColor(colors.HexColor('#475569'))
pdf.drawString(box_x + 25, content_y, "Passwort:")
pdf.setFont("Courier-Bold", 13)
pdf.setFillColor(colors.HexColor('#0F172A'))
pdf.drawString(box_x + 160, content_y, user['password'])
# Security Footer Note
content_y -= 45
pdf.setFont("Helvetica-Oblique", 9)
pdf.setFillColor(colors.HexColor('#64748B'))
pdf.drawString(box_x + 25, content_y, "Hinweis: Bitte ändern Sie Ihr Passwort nach der ersten Anmeldung.")
# Dashed Cut Line between top and bottom cards
if page_slot == 0 and i < len(created_users) - 1:
pdf.setDash(4, 4)
pdf.setStrokeColor(colors.HexColor('#94A3B8'))
pdf.line(0, card_height, width, card_height)
pdf.setDash() # Reset dash
pdf.save()
buffer.seek(0)
return buffer
@app.route('/register/csv', methods=['POST'])
def register_csv():
if 'username' not in session:
flash('Ihnen ist es nicht gestattet, diese Aktion auszuführen.', 'error')
return redirect(url_for('login'))
file = request.files.get('csv_file')
if not file or not file.filename.endswith('.csv'):
flash('Bitte laden Sie eine gültige CSV-Datei hoch.', 'error')
return redirect(url_for('register'))
permission_preset = (request.form.get('permission_preset') or 'standard_user').strip()
# 1. CSV über die robuste Funktion einlesen
file_bytes = file.stream.read()
raw_users = parse_csv_users(file_bytes)
if not raw_users:
flash('Keine gültigen Benutzer in der CSV-Datei gefunden. Bitte prüfen Sie das Format.', 'error')
return redirect(url_for('register'))
created_users = []
for entry in raw_users:
name = entry['name']
last_name = entry['last_name']
if not name or not last_name:
continue
# Benutzernamen & Passwort generieren
username = us.build_unique_username_from_name(name, last_name)
password = generate_compliant_password(16)
# In DB speichern
success = us.add_user(
username=username,
password=password,
name=name,
last_name=last_name,
is_student=False,
student_card_id=None,
max_borrow_days=None,
permission_preset=permission_preset,
)
if success:
created_users.append({
'name': name,
'last_name': last_name,
'username': username,
'password': password
})
if not created_users:
flash('Fehler beim Erstellen der Benutzer aus der CSV.', 'error')
return redirect(url_for('register'))
# PDF mit Zugangsdaten generieren
pdf_buffer = generate_credentials_pdf(created_users)
return send_file(
pdf_buffer,
as_attachment=True,
download_name='benutzer_zugangsdaten.pdf',
mimetype='application/pdf'
)
@app.route('/user_del', methods=['GET'])
def user_del():
"""
User deletion interface.
Displays a list of users that can be deleted by an administrator.
Prevents self-deletion by hiding the current user from the list.
"""
if 'username' not in session:
flash(
'Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adresse zu nutzen. Bitte melden Sie sich an!',
'error')
return redirect(url_for('login'))
# Abruf aller User (falls get_all_users tenant_id unterstützt, kann diese hier übergeben werden)
all_users = us.get_all_users()
users_list = []
for user in all_users:
username = user.get('Username')
if username and username != session.get('username'):
try:
permissions_payload = us.get_effective_permissions(username)
except Exception:
permissions_payload = us.build_default_permission_payload('standard_user')
try:
name = us.get_name(username)
last_name = us.get_last_name(username)
if name and last_name:
fullname = f"{name} {last_name}"
elif name:
fullname = name
elif last_name:
fullname = last_name
else:
fullname = None
except Exception:
name = ""
last_name = ""
fullname = None
users_list.append({
'username': decrypt_text(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 directly via the tenant db.
Includes safety checks to prevent self-deletion.
"""
if 'username' not in session:
flash('Ihnen ist es nicht gestattet, diese Aktion auszuführen. Bitte melden Sie sich an!', 'error')
return redirect(url_for('login'))
username = request.form.get('username')
if not username:
flash('Kein Benutzer ausgewählt', 'error')
return redirect(url_for('user_del'))
if username == session.get('username'):
flash('Sie können Ihr eigenes Konto nicht löschen', 'error')
return redirect(url_for('user_del'))
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
tenant_id = session.get('tenant_id')
db = us._get_tenant_db(client, tenant_id)
ausleihungen = db['ausleihungen']
items_col = db['items']
users_col = db['users'] # Direkter Zugriff auf die User-Collection
now = datetime.datetime.now()
# 1. Aktive Ausleihen abschließen
ausleihungen.update_many(
{'User': username, 'Status': 'active'},
{'$set': {'Status': 'completed', 'End': now, 'LastUpdated': now}}
)
# 2. Geplante Ausleihen stornieren
ausleihungen.update_many(
{'User': username, 'Status': 'planned'},
{'$set': {'Status': 'cancelled', 'LastUpdated': now}}
)
# 3. Inventar wieder verfügbar machen
items_col.update_many(
{'User': username},
{'$set': {'Verfuegbar': True, 'LastUpdated': now}, '$unset': {'User': ""}}
)
# 4. Den Benutzer direkt in der überprüften DB-Verbindung löschen
# Achte auf die exakte Großschreibung 'Username' (so wie in add_user definiert)
delete_result = users_col.delete_one({'Username': username})
# 5. Explizite Erfolgskontrolle
if delete_result.deleted_count > 0:
flash(f'Benutzer {username} erfolgreich gelöscht', 'success')
else:
# Fallback, falls der Nutzer nicht gefunden wurde (Fehlervermeidung)
flash(f'Löschen fehlgeschlagen: Benutzer {username} wurde in der Datenbank nicht gefunden.', 'error')
except Exception as e:
app.logger.error(f"Error resetting borrowings or deleting user {username}: {e}")
flash('Kritischer Fehler beim Löschen des Benutzers', 'error')
finally:
# Garantiert, dass die DB-Verbindung geschlossen wird
if 'client' in locals():
client.close()
return redirect(url_for('user_del'))
@app.route('/admin/borrowings')
def admin_borrowings():
"""
Admin view: list all active and planned borrowings with ability to reset.
"""
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'))
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['pages'].get('admin_borrowings', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
return redirect(url_for('home_admin'))
_ensure_audit_indexes_once()
from modules.inventarsystem.data_protection import decrypt_text
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
raw_user = r.get('User', '')
decrypted_user = decrypt_text(raw_user) if raw_user else ''
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': decrypted_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:
return jsonify({'ok': False, 'error': 'forbidden'}), 403
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['pages'].get('admin_audit_dashboard', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
return redirect(url_for('home_admin'))
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:
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'))
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['pages'].get('admin_audit_dashboard', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
return redirect(url_for('home_admin'))
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:
payload_raw = row.get("payload")
if payload_raw and isinstance(payload_raw, str):
try:
# Safely evaluate the python-like dict string, providing context for ObjectId
safe_context = {"ObjectId": ObjectId, "None": None, "True": True, "False": False}
payload_dict = eval(payload_raw, {"__builtins__": {}}, safe_context)
if isinstance(payload_dict, dict):
# Decrypt the sensitive keys inside the extracted dictionary
decrypt_document_fields(payload_dict, SENSITIVE_AUDIT_FIELDS)
# Replace the raw string with the clean dictionary for the Jinja template
row["payload"] = payload_dict
except Exception as parse_err:
app.logger.error(f"Failed to parse audit payload string for index {row.get('chain_index')}: {parse_err}")
elif payload_raw and isinstance(payload_raw, dict):
# Fallback in case some newer log rows are already stored as proper dictionaries
decrypt_document_fields(payload_raw, SENSITIVE_AUDIT_FIELDS)
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:
return jsonify({'ok': False, 'error': 'forbidden'}), 403
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['pages'].get('admin_audit_dashboard', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
return redirect(url_for('home_admin'))
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))
for row in audit_rows:
payload_raw = row.get("payload")
if payload_raw and isinstance(payload_raw, str):
try:
# Safely evaluate the python-like dict string with custom token support
safe_context = {"ObjectId": ObjectId, "None": None, "True": True, "False": False}
payload_dict = eval(payload_raw, {"__builtins__": {}}, safe_context)
if isinstance(payload_dict, dict):
# Decrypt the dictionary fields in-place
decrypt_document_fields(payload_dict, SENSITIVE_AUDIT_FIELDS)
# Replace string with the clean dictionary so the PDF generator can read it
row["payload"] = payload_dict
except Exception as parse_err:
app.logger.error(f"Failed to parse audit payload string for PDF export at index {row.get('chain_index')}: {parse_err}")
elif payload_raw and isinstance(payload_raw, dict):
# Fallback for standard dict payloads
decrypt_document_fields(payload_raw, SENSITIVE_AUDIT_FIELDS)
# 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/', 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:
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'))
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('home_admin'))
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//invoice', methods=['POST'])
def admin_create_invoice(borrow_id):
"""Create a PDF invoice for a destroyed borrowed item."""
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'))
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['pages'].get('admin_borrowings', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
return redirect(url_for('home_admin'))
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'))
student_cards = db['student_cards']
student_id = borrow_doc.get('AusweisId')
borrower = "Unbekannt"
# 1. Prüfen: Gibt es eine AusweisId?
if student_id:
try:
card = student_cards.find_one({'_id': ObjectId(student_id)})
except Exception:
card = student_cards.find_one({'_id': student_id})
if not card:
flash('Der zugehörige Schülerausweis wurde in der Datenbank nicht gefunden.', 'error')
return redirect(url_for('admin_borrowings'))
card = _decrypt_student_card_doc(card)
borrower = card.get('SchülerName', 'Unbekannt')
# 2. Fallback: Wenn keine AusweisId da ist, schauen wir, ob ein Name unter 'User' gespeichert wurde
else:
fallback_user = borrow_doc.get('User')
if fallback_user:
borrower = str(fallback_user)
else:
flash('Dieser Ausleihe ist weder eine AusweisId noch ein Benutzername zugeordnet.', 'error')
return redirect(url_for('admin_borrowings'))
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': decrypt_text(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//invoice/mark-paid', methods=['POST'])
def admin_mark_invoice_paid(borrow_id):
"""Mark an existing invoice as paid."""
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'))
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['pages'].get('admin_borrowings', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
return redirect(url_for('home_admin'))
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('library_loans_admin'))
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('library_loans_admin'))
finally:
if client:
client.close()
@app.route('/admin/borrowings//invoice/pay', methods=['POST'])
def admin_pay_invoice_extended(borrow_id):
"""Mark invoice as paid and complete borrowing if active."""
if 'username' not in session:
flash('Ihnen ist es nicht gestattet, versuchen sie es erneut nach Anmeldung!', 'error')
return redirect(url_for('login'))
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['pages'].get('library_loans_admin', False):
flash('Ihnen fehlen die nötigen Berechtigungen.', 'error')
return redirect(request.referrer or url_for('home_admin'))
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)})
if not borrow_doc:
flash('Ausleihung nicht gefunden.', 'error')
return redirect(request.referrer or url_for('library_loans_admin'))
invoice_data = borrow_doc.get('InvoiceData') or {}
if not invoice_data:
flash('Für diese Ausleihung existiert keine Rechnung.', 'warning')
return redirect(request.referrer or url_for('library_loans_admin'))
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})
try:
db['system_logs'].insert_one({
'type': '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"Log failed: {log_err}")
_append_audit_event_standalone(
event_type='invoice_paid',
payload={
'borrow_id': borrow_id,
'invoice_number': invoice_data.get('invoice_number', ''),
'amount': invoice_data.get('amount'),
}
)
flash('Rechnung erfolgreich als bezahlt markiert.', 'success')
return redirect(request.referrer or url_for('library_loans_admin'))
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(request.referrer or url_for('library_loans_admin'))
finally:
if client:
client.close()
@app.route('/admin/items//repair', methods=['POST'])
def resolve_repaired_item(item_id):
"""Mark item as repaired and make it available again."""
if 'username' not in session:
return redirect(url_for('login'))
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
items_col = db['items']
try:
obj_id = ObjectId(item_id)
except Exception:
obj_id = item_id
item_doc = items_col.find_one({'_id': obj_id})
if not item_doc:
flash('Element nicht gefunden.', 'error')
return redirect(request.referrer or url_for('library_loans_admin'))
now = datetime.datetime.now()
open_reports = item_doc.get('DamageReports', []) or []
item_update = {
'$set': {
'DamageReports': [],
'HasDamage': False,
'Verfuegbar': True,
'LastUpdated': now,
},
'$unset': {
'User': '',
'Condition': '',
},
}
# Historie der Reparaturen speichern
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}}
items_col.update_one({'_id': item_doc['_id']}, item_update)
flash('Element wurde erfolgreich als repariert markiert und ist wieder verfügbar.', 'success')
return redirect(request.referrer or url_for('library_loans_admin'))
except Exception as e:
app.logger.error(f"Error repairing item {item_id}: {e}")
flash('Fehler bei der Reparatur.', 'error')
return redirect(request.referrer or url_for('library_loans_admin'))
finally:
if client:
client.close()
@app.route('/admin/borrowings//invoice/pdf', methods=['GET'])
def admin_view_invoice_pdf(borrow_id):
"""View a previously created invoice PDF for a borrowing."""
if 'username' not in session:
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'))
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['pages'].get('admin_borrowings', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
return redirect(url_for('home_admin'))
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//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:
flash('Bitte melden Sie sich an, um auf diese Seite zuzugreifen.', 'error')
return redirect(url_for('login'))
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['pages'].get('admin_borrowings', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
return redirect(url_for('home_admin'))
if not ObjectId.is_valid(borrow_id):
flash('Ungültige Ausleih-ID.', 'error')
return redirect(url_for('admin_borrowings'))
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/items//resolve_repair', methods=['POST'])
def admin_resolve_repair(item_id):
"""Flask-Route für die Reparatur-Aktionen (repair, replace, delete)."""
if 'username' not in session:
flash('Bitte melden Sie sich an, um fortzufahren.', 'error')
return redirect(url_for('login'))
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['pages'].get('library_loans_admin', False):
flash('Ihnen fehlen die nötigen Berechtigungen für diese Aktion.', 'error')
return redirect(url_for('home_admin'))
action = request.form.get('action', 'repair')
new_code_4 = request.form.get('new_code_4', '')
current_user = session.get('username', 'admin')
success, message = resolve_repaired_item_funct(item_id, action, new_code_4, current_user)
if success:
flash(message, 'success')
else:
flash(message, 'error')
return redirect(url_for('library_loans_admin'))
def resolve_repaired_item_funct(item_id, action, new_code_4="", current_user="admin"):
"""
Verarbeitet Items aus der Reparatur und aktualisiert die Serien-Counts
sowie Parent/Child-Abhängigkeiten beim Löschen korrekt.
Verschiebt gemeldete Schäden in die Historie (DamageRepairs) bei Reparatur/Austausch.
"""
if not ObjectId.is_valid(item_id):
return False, "Ungültige Item-ID."
try:
with MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) as client:
db = client[cfg.MONGODB_DB]
items = db['items']
borrowings = db['borrowings'] if 'borrowings' in db.list_collection_names() else None
item = items.find_one({'_id': ObjectId(item_id)})
if not item:
return False, "Item nicht in der Datenbank gefunden."
series_group_id = item.get('SeriesGroupId')
now = datetime.datetime.now()
legacy_damage_unset = {
'has_damage': "",
'DamageCount': "",
'damage_count': "",
'DamageText': "",
'damage_text': "",
'is_damaged': "",
'IsDamaged': ""
}
borrowings_unset = legacy_damage_unset.copy()
borrowings_unset.update({
'HasDamage': "",
'Condition': "",
'DamageReports': ""
})
# --- OPTION 1: DELETE ---
if action == 'delete':
is_parent = not item.get('IsGroupedSubItem')
if is_parent and series_group_id:
sibling = items.find_one({
'SeriesGroupId': series_group_id,
'_id': {'$ne': ObjectId(item_id)},
'Deleted': {'$ne': True}
})
if sibling:
new_parent_id_str = str(sibling['_id'])
items.update_one(
{'_id': sibling['_id']},
{'$set': {
'IsGroupedSubItem': False,
'ParentItemId': None
}}
)
items.update_many(
{
'SeriesGroupId': series_group_id,
'_id': {'$nin': [ObjectId(item_id), sibling['_id']]}
},
{'$set': {'ParentItemId': new_parent_id_str}}
)
items.delete_one({'_id': ObjectId(item_id)})
if series_group_id:
items.update_many(
{'SeriesGroupId': series_group_id},
{'$inc': {'SeriesCount': -1}}
)
return True, "Item wurde gelöscht und der Bestand aller Serien-Items angepasst."
current_reports = item.get('DamageReports', [])
repair_entry = {
'repaired_by': current_user,
'repaired_at': now,
'action_taken': action,
'resolved_reports': current_reports
}
# --- OPTION 2: REPLACE ---
if action == 'replace':
items.update_one(
{'_id': ObjectId(item_id)},
{
'$push': {'DamageRepairs': repair_entry},
'$set': {
'Code_4': str(new_code_4).strip(),
'Verfuegbar': True,
'HasDamage': False,
'Condition': 'gut',
'DamageReports': [],
'LastUpdated': now
},
'$unset': legacy_damage_unset
}
)
if borrowings is not None:
borrowings.update_many(
{'$or': [{'item_id': str(item_id)}, {'item_id': ObjectId(item_id)}]},
{'$unset': borrowings_unset}
)
return True, f"Item wurde ersetzt. Neuer Code 4 zugewiesen: {new_code_4}."
# --- OPTION 3: JUST REPAIR ---
elif action == 'repair':
items.update_one(
{'_id': ObjectId(item_id)},
{
'$push': {'DamageRepairs': repair_entry},
'$set': {
'Verfuegbar': True,
'HasDamage': False,
'Condition': 'gut',
'DamageReports': [],
'LastUpdated': now
},
'$unset': legacy_damage_unset
}
)
if borrowings is not None:
borrowings.update_many(
{'$or': [{'item_id': str(item_id)}, {'item_id': ObjectId(item_id)}]},
{'$unset': borrowings_unset}
)
return True, "Item wurde repariert und ist wieder regulär verfügbar."
else:
return False, "Ungültige Aktion ausgewählt."
except Exception as e:
print(f"Error resolving repair for item {item_id}: {e}")
return False, "Ein Datenbankfehler ist aufgetreten."
@app.route('/admin/library/items//invoices', methods=['GET'])
def library_item_invoices(item_id):
"""Show all stored invoices and damages for one specific library item."""
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('library'):
flash('Bibliotheks-Modul ist deaktiviert.', 'error')
return redirect(url_for('home_admin'))
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['pages'].get('library_loans_admin', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
return redirect(url_for('library_view'))
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'))
item_id_obj = item_doc.get('_id')
item_id_str = str(item_id_obj)
# ==========================================
# 1. RECHNUNGSHISTORIE (Aus Ausleihen)
# ==========================================
borrow_docs = list(ausleihungen.find(
{
'Item': {'$in': [item_id_str, item_id_obj]},
'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': decrypt_text(borrow_doc.get('User', '')) if hasattr(borrow_doc,
'get') and borrow_doc.get(
'User') else '',
'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')) if hasattr(invoice_data,
'get') else '0,00',
'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', ''),
})
# ==========================================
# 2. SCHADENSHISTORIE (Neues Format + Fallback)
# ==========================================
raw_damages = []
# A: Aktive / gemeldete Schäden aus 'DamageReports'
damage_reports = item_doc.get('DamageReports', [])
if not isinstance(damage_reports, list):
damage_reports = []
for d in damage_reports:
if not isinstance(d, dict): continue
# Rechnungs-Metadaten anhängen, falls vorhanden
extra_info = ""
if d.get('invoice_number'):
amount = d.get('invoice_amount', '')
extra_info = f"\n(Mit Rechnung: {d['invoice_number']} - {amount}€)"
raw_damages.append({
'raw_date': d.get('reported_at'),
'source': str(d.get('source', 'Meldung')).capitalize(),
'user': d.get('reported_by', 'System/Admin'),
'description': d.get('description', 'Keine Beschreibung') + extra_info,
'status': 'Offen',
'borrow_id': None
})
# B: Behobene Schäden aus 'DamageRepairs'
damage_repairs = item_doc.get('DamageRepairs', [])
if not isinstance(damage_repairs, list):
damage_repairs = []
for r in damage_repairs:
if not isinstance(r, dict): continue
resolved_list = r.get('resolved_reports', [])
repaired_at = r.get('repaired_at')
repaired_by = r.get('repaired_by', 'Admin')
action_taken = r.get('action_taken', 'repair')
# Status anhand der Aktion bestimmen
status_text = 'Repariert' if action_taken == 'repair' else 'Erledigt'
if resolved_list and isinstance(resolved_list, list):
for res in resolved_list:
# Rechnungs-Metadaten aus dem ehemals offenen Report
extra_info = ""
if res.get('invoice_number'):
amount = res.get('invoice_amount', '')
extra_info = f"\n(Mit Rechnung: {res['invoice_number']} - {amount}€)"
raw_damages.append({
'raw_date': res.get('reported_at') or repaired_at,
'source': f"{status_text} von {repaired_by}",
'user': res.get('reported_by', 'System/Admin'),
'description': res.get('description', 'Keine Beschreibung') + extra_info,
'status': status_text,
'borrow_id': None
})
else:
# Fallback, falls keine genauen Reports hinterlegt sind
raw_damages.append({
'raw_date': repaired_at,
'source': 'Reparatur',
'user': repaired_by,
'description': 'Schaden behoben',
'status': status_text,
'borrow_id': None
})
# C: Legacy-Fallback: Schäden, die evtl. nur in alten Ausleih-Dokumenten vermerkt sind
damage_borrow_docs = list(ausleihungen.find(
{
'Item': {'$in': [item_id_str, item_id_obj]},
'$or': [
{'Damage': {'$exists': True, '$ne': ''}},
{'DamageData': {'$exists': True, '$ne': {}}},
{'Condition': {'$exists': True, '$ne': ''}}
]
}
))
for b in damage_borrow_docs:
borrow_user = decrypt_text(b.get('User', '')) if b.get('User') else '—'
d_data = b.get('DamageData') or {}
if not isinstance(d_data, dict): d_data = {}
desc = d_data.get('description') or b.get('Damage') or b.get('Condition')
if desc and str(desc).strip():
raw_damages.append({
'raw_date': d_data.get('reported_at') or b.get('End') or b.get('Start'),
'source': "Ausleihe",
'user': borrow_user,
'description': desc,
'status': d_data.get('status', 'Gemeldet'),
'borrow_id': str(b.get('_id'))
})
# Daten chronologisch (neueste zuerst) sortieren
def get_sort_key(entry):
d = entry['raw_date']
return d if isinstance(d, datetime.datetime) else datetime.datetime.min
raw_damages.sort(key=get_sort_key, reverse=True)
# Datums-Objekte für das Template formatieren
damage_entries = []
for entry in raw_damages:
d_date = entry.pop('raw_date')
entry['date'] = d_date.strftime('%d.%m.%Y %H:%M') if isinstance(d_date, datetime.datetime) else str(
d_date or 'Unbekannt')
damage_entries.append(entry)
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,
damages=damage_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/damage history for item {item_id}: {e}")
flash('Fehler beim Laden der Rechnungs- oder Schadenshistorie.', '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:
flash('Nicht autorisierter Zugriff', 'error')
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('home_admin'))
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
"""
from modules.inventarsystem.data_protection import decrypt_text
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'))
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['pages'].get('logs', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
return redirect(url_for('home_admin'))
# 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'
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': decrypt_text(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']}}))
from bson.objectid import ObjectId
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': decrypt_text(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': decrypt_text(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'))
current_permissions = us.get_effective_permissions(session['username'])
if current_permissions['actions'].get('can_manage_users', False):
return jsonify(us.get_all_users())
@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'))
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['pages'].get('manage_filters', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
return redirect(url_for('home_admin'))
# 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/', 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:
return jsonify({'success': False, 'error': 'Not authorized'}), 403
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['actions'].get('can_edit', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
return redirect(url_for('home_admin'))
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//', 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:
return jsonify({'success': False, 'error': 'Not authorized'}), 403
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['actions'].get('can_edit', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
return redirect(url_for('home_admin'))
# 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//', 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
"""
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['actions'].get('can_edit', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
return redirect(url_for('home_admin'))
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/')
def get_predefined_filter_values(filter_num):
"""
API endpoint to get predefined values for a specific filter.
Args:
filter_num (int): Filter number (1 or 2)
Returns:
dict: Dictionary containing predefined filter values
"""
values = it.get_predefined_filter_values(filter_num)
return jsonify({'values': values})
@app.route('/search_word/')
def search_word(word):
"""Search items by Titel (Name) and Beschreibung, case-insensitive.
Returns: JSON with list of matching item IDs.
"""
try:
term = (word or "").strip()
if not term:
return jsonify({"success": True, "response": []})
term_lower = term.lower()
id_set = set()
for i in it.get_items():
beschreibung = i.get("Beschreibung", "")
titel = i.get("Name", "")
# Normalize Beschreibung to string
try:
if isinstance(beschreibung, (list, tuple)):
text = " ".join([str(x) for x in beschreibung])
else:
text = str(beschreibung)
except Exception:
text = ""
# Normalize title
try:
title_text = str(titel)
except Exception:
title_text = ""
if (term_lower in text.lower()) or (term_lower in title_text.lower()):
_id = i.get("_id")
if _id is not None:
id_set.add(str(_id))
return jsonify({"success": True, "response": list(id_set)})
except Exception as e:
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/')
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:
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'))
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 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):
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
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
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
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
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 (max 5MB)"}), 413
except ValueError:
pass
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}"
image_data = io.BytesIO()
written = 0
for chunk in response.iter_content(chunk_size=8192):
written += len(chunk)
if written > 5 * 1024 * 1024:
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 and stored directly in database"
})
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}")
return jsonify({"error": "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,
schließt jedoch soft-gelöschte Objekte (Deleted: True) aus.
"""
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']
all_ausleihungen = list(ausleihungen_collection.find({
'Status': {'$in': ['active', 'planned']}
}))
active_items = []
planned_items = []
processed_item_ids = set()
for appointment in all_ausleihungen:
raw_user = appointment.get('User', '')
try:
decrypted_user = decrypt_text(raw_user) if raw_user else ''
except Exception as e:
app.logger.error(f"Entschlüsselungsfehler: {e}")
decrypted_user = ''
if decrypted_user != username:
continue
item_id = appointment.get('Item')
if not item_id:
continue
try:
if isinstance(item_id, str):
query_id = ObjectId(item_id)
else:
query_id = item_id
item_obj = items_collection.find_one({
'_id': query_id,
'Deleted': {'$ne': True}
})
except Exception:
item_obj = None
if item_obj:
item_obj['_id'] = str(item_obj['_id'])
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')),
}
status = appointment.get('Status')
if status == 'active':
item_obj['ActiveAppointment'] = True
if str(item_obj['_id']) not in processed_item_ids:
active_items.append(item_obj)
processed_item_ids.add(str(item_obj['_id']))
elif status == 'planned':
planned_items.append(item_obj)
all_borrowed_items = list(items_collection.find({
'Verfuegbar': False,
'Deleted': {'$ne': True}
}))
for item in all_borrowed_items:
raw_item_user = item.get('User', '')
try:
dec_item_user = decrypt_text(raw_item_user) if raw_item_user else ''
except Exception:
dec_item_user = ''
if dec_item_user == username and str(item['_id']) not in processed_item_ids:
item['_id'] = str(item['_id'])
item['ActiveAppointment'] = True
item['AppointmentData'] = {'status': 'active (no document)'}
active_items.append(item)
processed_item_ids.add(item['_id'])
client.close()
app.logger.info(
f"Passing {len(active_items)} active items and {len(planned_items)} planned items to template for user {username}")
return render_template(
'my_borrowed_items.html',
items=active_items,
planned_items=planned_items
)
@app.route('/api/push/vapid-key', methods=['GET'])
def get_vapid_key():
"""
Returns the VAPID public key for web push subscriptions
"""
from Web.push_notifications import _get_vapid_public
if 'username' not in session:
return jsonify({'success': False, 'error': 'Not authenticated'}), 401
try:
if not _get_vapid_public():
return jsonify({'success': False, 'error': 'VAPID public key not configured'}), 500
return jsonify({
'success': True,
'publicKey': _get_vapid_public()
})
except Exception as e:
app.logger.error(f'Error getting VAPID key: {e}')
return jsonify({'success': False}), 500
@app.route('/notifications')
def notifications_view():
"""Notification center for users and admins."""
from Web.push_notifications import _get_vapid_public
if 'username' not in session:
flash('Bitte melden Sie sich an, um Benachrichtigungen zu sehen.', 'error')
return redirect(url_for('login'))
username = session['username']
current_permissions = us.get_effective_permissions(session['username'])
is_admin_user = current_permissions['actions'].get('can_manage_settings', 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 [])
created_at_val = notif.get('CreatedAt')
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': created_at_val if created_at_val else None,
'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'),
vapid_public_key=_get_vapid_public()
)
except Exception as exc:
app.logger.error(f"Error loading notifications: {exc}")
flash('Fehler beim Laden der Benachrichtigungen.', 'error')
return redirect(url_for('home_admin'))
finally:
if client:
client.close()
@app.route('/notifications/mark_read/', 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():
if 'username' not in session:
flash('Bitte melden Sie sich an.', 'error')
return redirect(url_for('login'))
username = session['username']
current_permissions = us.get_effective_permissions(username)
is_admin_user = current_permissions['actions'].get('can_manage_settings', False)
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
# Filter-Logik: Welche Benachrichtigungen sollen als gelesen markiert werden?
# Wenn Sie 'Audience' nutzen (wie in Ihrer notifications_view Route):
query = {}
if is_admin_user:
# Ein Admin sieht sowohl an ihn gerichtete als auch allgemeine Admin-Meldungen
query['$or'] = [
{'TargetUser': username},
{'Audience': 'admin'}
]
else:
# Normale User sehen nur Meldungen, die an sie oder alle User gerichtet sind
query['$or'] = [
{'TargetUser': username},
{'Audience': 'user'}
]
# WICHTIG: Fügt den Benutzernamen per $addToSet in das ReadBy-Array ein.
# $addToSet verhindert, dass der Name doppelt eingetragen wird, falls er schon drinsteht.
db['notifications'].update_many(
query,
{'$addToSet': {'ReadBy': username}}
)
flash('Alle Benachrichtigungen wurden als gelesen markiert.', 'success')
except Exception as exc:
app.logger.error(f"Error marking all notifications as read: {exc}")
flash('Fehler beim Aktualisieren der Benachrichtigungen.', 'error')
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
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['actions'].get('can_manage_settings', False):
is_admin_user = True
else:
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():
"""Admin-Übersicht aller defekten, reparierten oder zerstörten Items."""
if 'username' not in session:
flash('Anmeldung erforderlich.', 'error')
return redirect(url_for('login'))
from modules.inventarsystem.data_protection import decrypt_text
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
items_col = db['items']
ausleihungen_col = db['ausleihungen']
# 1. Alle Items suchen, die einen Schaden haben, repariert oder zerstört sind
query = {
'$or': [
{'HasDamage': True},
{'DamageReports': {'$exists': True, '$not': {'$size': 0}}},
{'Condition': {'$in': ['destroyed', 'broken', 'damaged', 'repaired', 'fixed']}}
]
}
raw_items = list(items_col.find(query).sort('LastUpdated', -1))
damaged_items = []
for item in raw_items:
# Dynamischen Status ermitteln (wie zuvor besprochen)
condition_value = str(item.get('Condition', '')).strip().lower()
has_damage_flag = bool(item.get('HasDamage'))
has_reports = bool(item.get('DamageReports'))
if condition_value == 'destroyed':
status_text = 'Komplett zerstört'
status_color = 'danger' # Rot
elif condition_value in ['repaired', 'fixed'] or (has_reports and not has_damage_flag):
status_text = 'Bereits repariert'
status_color = 'success' # Grün
else:
status_text = 'Aktuelles Problem'
status_color = 'warning' # Gelb
# Schadensberichte analysieren
damage_reports = item.get('DamageReports', [])
latest_report = damage_reports[-1] if damage_reports else {}
# Prüfen, ob es eine aktive oder geplante Ausleihe für dieses Item gibt
active_borrow = ausleihungen_col.find_one({
'Item': item.get('_id'), # Suche mit ObjectId
'Status': {'$in': ['active', 'planned']}
})
# Falls die ID in der Ausleihen-Collection als String hinterlegt ist (Fallback)
if not active_borrow:
active_borrow = ausleihungen_col.find_one({
'Item': str(item.get('_id')),
'Status': {'$in': ['active', 'planned']}
})
# Benutzernamen der Ausleihe entschlüsseln
if active_borrow and active_borrow.get('User'):
active_borrow['User'] = decrypt_text(active_borrow['User'])
# Direkt am Item hinterlegten Nutzer entschlüsseln
item_user = decrypt_text(item.get('User')) if item.get('User') else ''
# Datenstruktur exakt für dein Jinja2 Template aufbauen
damaged_items.append({
'id': str(item['_id']),
'name': item.get('Name', ''),
'code': item.get('Code_4', ''),
'item_type': item.get('ItemType', ''),
'author': item.get('Author', item.get('Autor', '')), # Deckt beide Schreibweisen ab
'isbn': item.get('ISBN', ''),
'borrow_user': item_user,
'damage_count': len(damage_reports),
'condition': item.get('Condition', '-'),
'available': item.get('Verfuegbar', False),
# Letzte Meldung
'latest_damage_description': latest_report.get('description', ''),
'latest_damage_by': latest_report.get('reported_by', ''),
'latest_damage_at': latest_report.get('reported_at', ''),
# Status & Ausleihe
'status_text': status_text,
'status_color': status_color,
'active_borrow': active_borrow
})
return render_template(
'admin_damaged_items.html',
damaged_items=damaged_items,
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"Fehler beim Laden der defekten Items: {exc}")
flash('Fehler beim Laden der Übersicht.', '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:
return jsonify({'success': False, 'error': 'Not authorized'}), 403
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['actions'].get('can_insert', False) or not current_permissions['actions'].get('can_edit', True):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
return redirect(url_for('home_admin'))
value = sanitize_form_value(request.form.get('value'))
if not value:
flash('Bitte geben Sie einen Wert ein', 'error')
return redirect(url_for('manage_locations'))
# Add the value to locations
success = it.add_predefined_location(value)
if success:
flash(f'Ort "{value}" wurde zur Liste hinzugefügt', 'success')
else:
flash(f'Ort "{value}" existiert bereits', 'error')
return redirect(url_for('manage_locations'))
@app.route('/remove_location_value/', methods=['POST'])
def remove_location_value(value):
"""
Remove a predefined location value.
Args:
value (str): Value to remove
Returns:
flask.Response: Redirect to location management page
"""
if 'username' not in session:
return jsonify({'success': False, 'error': 'Not authorized'}), 403
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['actions'].get('can_delete', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
return redirect(url_for('home_admin'))
# 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'))
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['actions'].get('can_edit', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
return redirect(url_for('home_admin'))
# 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'))
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['actions'].get('can_manage_settings', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', '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 ''
fs = get_gridfs()
# Remove previous logo files from GridFS if different
if previous_logo_path and previous_logo_path != saved_logo_filename:
try:
old_logo_file = fs.find_one({'filename': previous_logo_path})
if old_logo_file:
fs.delete(old_logo_file._id)
except Exception:
pass
if previous_thumb_path and previous_thumb_path != saved_thumb_filename:
try:
old_thumb_file = fs.find_one({'filename': previous_thumb_path})
if old_thumb_file:
fs.delete(old_thumb_file._id)
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/')
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/', 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}, Status={ausleihung_status}")
current_permissions = us.get_effective_permissions(session['username'])
# Check if the ausleihung belongs to the current user
if ausleihung_user != username and not (current_permissions['actions'].get('can_manage_users', False)):
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/', 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
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('home_admin'))
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/')
def serve_static(filename):
"""
Explicitly serve static files to resolve 403 Forbidden errors.
This ensures CSS and JS files are properly accessible.
Args:
filename (str): The static file path
Returns:
flask.Response: The requested static file
"""
return send_from_directory(app.static_folder, filename)
@app.route('/static/css/')
def serve_css(filename):
"""
Explicitly serve CSS files from the static/css directory.
Args:
filename (str): Name of the CSS file to serve
Returns:
flask.Response: The requested CSS file
"""
css_folder = os.path.join(app.static_folder, 'css')
return send_from_directory(css_folder, filename)
@app.route('/static/js/')
def serve_js(filename):
"""
Explicitly serve JavaScript files from the static/js directory.
Args:
filename (str): Name of the JS file to serve
Returns:
flask.Response: The requested JS file
"""
js_folder = os.path.join(app.static_folder, 'js')
return send_from_directory(js_folder, filename)
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 we’re within 10% of target, that’s good enough
if abs(size - target_size_bytes) < (target_size_bytes * 0.1):
return quality
return best_quality
# ============================================================================
# PUSH NOTIFICATION API ENDPOINTS
# ============================================================================
@app.route('/health')
def health_check():
return 'OK', 200
@app.route('/api/push/subscribe', methods=['POST'])
def subscribe_to_push():
if 'username' not in session:
return jsonify({'success': False, 'error': 'Unauthorized'}), 401
data = request.get_json(silent=True) or {}
subscription_obj = data.get('subscription') if 'subscription' in data else data
if not subscription_obj or not isinstance(subscription_obj, dict):
app.logger.error("Invalid subscription payload received")
return jsonify({'success': False, 'error': 'Invalid payload'}), 400
username = session['username']
try:
success = pn.save_push_subscription(username, subscription_obj)
if success:
return jsonify({'success': True})
else:
return jsonify({'success': False, 'error': 'Failed to save in DB'}), 400
except Exception as e:
app.logger.error(f"Error in subscribe_to_push route: {e}")
return jsonify({'success': False, 'error': 'Internal server error'}), 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(silent=True) 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/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
try:
data = request.get_json(silent=True) 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
@app.route('/batch_upload', methods=['GET'])
def batch_upload_page():
"""
Serves the HTML frontend for the batch CSV and image upload.
"""
# Check permissions if necessary, similar to your other routes
if 'username' not in session:
flash('Bitte melden Sie sich an.', 'error')
return redirect(url_for('login'))
return render_template('upload_batch.html')
def clean_db_field(val):
"""Bereinigt Werte, die fälschlicherweise als String-Listen oder mit Klammern aus der CSV kommen."""
import ast
import pandas as pd
if not val or pd.isna(val):
return None
val_str = str(val).strip()
# Wenn es wie eine Liste aussieht (z.B. "['100177']" oder "['']")
if val_str.startswith("[") and val_str.endswith("]"):
try:
parsed = ast.literal_eval(val_str)
if isinstance(parsed, list):
# Nimm das erste Element der Liste, wenn vorhanden
for item in parsed:
cleaned_item = str(item).strip()
if cleaned_item and cleaned_item not in ("", "''", '""', "None", "nan"):
return cleaned_item
return None
except Exception:
# Fallback bei Syntaxfehlern
inner = val_str[1:-1].strip().replace("'", "").replace('"', '')
return inner if inner and inner not in ("''", '""') else None
if val_str in ("[]", "['']", '[""]', "nan", "None", "''", '""'):
return None
return val_str
@app.route('/upload_csv_batch', methods=['POST'])
def upload_csv_batch():
"""
Route for batch adding new items to the inventory via CSV.
Handles CSV parsing, bulk image upload with deduplication (SHA-256 hash matching),
GridFS storage, code generation, location syncing, and grouped item creation.
"""
import pandas as pd
import ast
import hashlib
username = session.get('username', 'System')
def generate_unique_batch_code(base_code, position):
if base_code:
return f"{base_code}-{position}"
else:
random_prefix = str(uuid.uuid4())[:6].upper()
return f"BATCH-{random_prefix}-{position}"
fs = get_gridfs()
upload_session_id = str(uuid.uuid4())[:8]
app.logger.info(f"Starting CSV Batch upload session {upload_session_id} - User: {username}")
# 1. Dateien aus dem Request empfangen
if 'csv_file' not in request.files:
return jsonify({"success": False, "message": "Keine CSV-Datei hochgeladen"}), 400
csv_file = request.files['csv_file']
uploaded_images = request.files.getlist('images')
# 2. CSV Einlesen und Validieren
try:
df = pd.read_csv(csv_file, sep=',')
except Exception as e:
app.logger.error(f"[Upload {upload_session_id}] Fehler beim Lesen der CSV: {str(e)}")
return jsonify({"success": False, "message": f"Fehler beim Lesen der CSV: {str(e)}"}), 400
if 'Name' not in df.columns:
return jsonify({"success": False, "message": "Die CSV muss zwingend eine 'Name' Spalte enthalten."}), 400
# 3. Bilder verarbeiten & Duplikate im selben Durchlauf filtern (Hash-Matching)
image_mapping = {}
processed_hashes = {}
processed_count = 0
dedup_count = 0
error_count = 0
for index, image in enumerate(uploaded_images):
if not image or not image.filename:
continue
original_secure_name = secure_filename(image.filename)
base_name_no_ext = os.path.splitext(original_secure_name)[0]
image_log_prefix = f"[Upload {upload_session_id}][Image {index + 1}/{len(uploaded_images)}]"
try:
image.seek(0)
image_bytes = image.read()
if not image_bytes:
error_count += 1
continue
img_hash = hashlib.sha256(image_bytes).hexdigest()
if img_hash in processed_hashes:
existing_filename = processed_hashes[img_hash]
image_mapping[base_name_no_ext] = existing_filename
dedup_count += 1
continue
optimized_io = io.BytesIO()
with Image.open(io.BytesIO(image_bytes)) as img:
if img.mode not in ('RGB', 'RGBA'):
img = img.convert('RGBA')
max_width = 500
if img.width > max_width:
ratio = max_width / img.width
new_size = (max_width, int(img.height * ratio))
img = img.resize(new_size, Image.Resampling.LANCZOS)
img.save(optimized_io, format='WEBP', quality=85, optimize=True)
optimized_io.seek(0)
new_filename = f"{uuid.uuid4().hex}_{int(time.time())}.webp"
fs.put(
optimized_io,
filename=new_filename,
content_type='image/webp',
metadata={
'original_filename': original_secure_name,
'upload_session': upload_session_id,
'batch_upload': True
}
)
processed_hashes[img_hash] = new_filename
image_mapping[base_name_no_ext] = new_filename
processed_count += 1
except Exception as e:
app.logger.error(f"{image_log_prefix} Processing failed: {str(e)}")
error_count += 1
# 4. Predefined Locations laden
try:
predefined_locations = it.get_predefined_locations()
except Exception:
predefined_locations = []
# 5. Dataframe bereinigen
df['Name'] = df['Name'].fillna('Unbenannt').astype(str).str.strip()
df = df.fillna({
'Ort': 'Unbekannt',
'Beschreibung': '',
'Code_4': '',
'Anschaffungsjahr': '',
'Anschaffungskosten': ''
})
# --- WICHTIG: Gruppierung über einen normalisierten Schlüssel ermöglichen ---
# Erstellt eine unsichtbare Hilfsspalte, die Leerzeichen/Groß-Kleinschreibung ignoriert,
# damit identische Artikel-Typen sauber als Serie erkannt werden.
df['GroupKey'] = df['Name'].str.lower()
created_item_ids = []
grouped_items = df.groupby('GroupKey')
for group_key, group in grouped_items:
item_count = len(group)
series_group_id = str(uuid.uuid4()) if item_count > 1 else None
parent_item_id = None
# Originalen Namen des ersten Elements der Gruppe übernehmen
actual_group_name = group.iloc[0]['Name']
# Basis-Code für automatisierte Seriencodes ermitteln
first_row_code = clean_db_field(group.iloc[0].get('Code_4', ''))
base_code = first_row_code if first_row_code else None
for position, (index, row) in enumerate(group.iterrows(), start=1):
ort_val = str(row['Ort']).strip()
if ort_val and ort_val not in predefined_locations:
try:
it.add_predefined_location(ort_val)
predefined_locations.append(ort_val)
except Exception as e:
app.logger.warning(f"Ort {ort_val} konnte nicht hinzugefügt werden: {e}")
# Bilder zuordnen und pro Artikel deduplizieren
item_image_filenames = []
if 'Images' in row and pd.notna(row['Images']):
try:
img_list = ast.literal_eval(str(row['Images']))
if isinstance(img_list, list):
for img_name in img_list:
base_img_name = os.path.splitext(img_name)[0]
if base_img_name in image_mapping:
item_image_filenames.append(image_mapping[base_img_name])
except (ValueError, SyntaxError):
pass
unique_image_filenames = []
for img in item_image_filenames:
if img not in unique_image_filenames:
unique_image_filenames.append(img)
def parse_filter_col(col_data):
try:
res = ast.literal_eval(str(col_data))
return res if isinstance(res, list) else []
except Exception:
return []
filter_upload = parse_filter_col(row.get('Filter', '[]'))
filter_upload2 = parse_filter_col(row.get('Filter2', '[]'))
filter_upload3 = parse_filter_col(row.get('Filter3', '[]'))
reservierbar = bool(row.get('Reservierbar', False))
# Code_4 / Barcode sauber extrahieren und bereinigen
raw_code = row.get('Code_4') or row.get('Barcode') or ''
row_code = clean_db_field(raw_code)
if row_code:
unique_code = row_code
elif item_count > 1:
unique_code = generate_unique_batch_code(base_code, position)
else:
unique_code = None
# DB Insert
item_id = it.add_item(
str(actual_group_name), # 1. Name
ort_val, # 2. Ort
str(row['Beschreibung']), # 3. Beschreibung
unique_image_filenames, # 4. Image Filenames (GridFS)
filter_upload, # 5. Filter 1
filter_upload2, # 6. Filter 2
filter_upload3, # 7. Filter 3
clean_db_field(row.get('Anschaffungsjahr')), # 8. Jahr
clean_db_field(row.get('Anschaffungskosten')),# 9. Kosten
unique_code, # 10. Unique Code / Code_4
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=str(row.get('ISBN', '')),
item_type=str(row.get('Item_Type', 'other')),
library_category=str(row.get('Library_Category', '')),
is_library=bool(row.get('Is_Library', False))
)
if item_id:
created_item_ids.append(item_id)
if position == 1:
parent_item_id = str(item_id)
else:
app.logger.error(f"Fehler beim Erstellen von Item: {actual_group_name} (Index {index})")
return jsonify({
"success": True,
"message": f"Upload erfolgreich. {len(created_item_ids)} Items importiert.",
"created_count": len(created_item_ids),
"images_processed": processed_count,
"images_deduplicated": dedup_count,
"images_failed": error_count
}), 200