Compare commits

...

11 Commits

17 changed files with 1361 additions and 116 deletions
Binary file not shown.
+616 -59
View File
@@ -33,7 +33,6 @@ import ausleihung as au
import audit_log as al
import datetime
from apscheduler.schedulers.background import BackgroundScheduler
from pymongo import MongoClient
from bson.objectid import ObjectId
from urllib.parse import urlparse, urlunparse
import requests
@@ -59,6 +58,7 @@ import subprocess
# Set base directory and centralized settings
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
import settings as cfg
from settings import MongoClient
app = Flask(__name__, static_folder='static') # Correctly set static folder
@@ -895,6 +895,394 @@ def normalize_and_validate_isbn(isbn_raw):
return ''
def _normalize_excel_header(value):
"""Normalize Excel header labels for robust auto-mapping."""
if value is None:
return ''
text = str(value).strip().lower()
text = text.replace('ä', 'ae').replace('ö', 'oe').replace('ü', 'ue').replace('ß', 'ss')
text = re.sub(r'[^a-z0-9]+', '_', text)
text = re.sub(r'_+', '_', text).strip('_')
return text
def _excel_bool(value, default=True):
"""Parse a flexible Excel bool-like cell value."""
if value is None:
return default
if isinstance(value, bool):
return value
text = str(value).strip().lower()
if text in {'1', 'true', 'yes', 'y', 'ja', 'j', 'x'}:
return True
if text in {'0', 'false', 'no', 'n', 'nein'}:
return False
return default
def _excel_int(value):
"""Parse int-like values from Excel cells."""
if value is None or str(value).strip() == '':
return None
try:
return int(float(str(value).replace(',', '.')))
except Exception:
return None
def _excel_float(value):
"""Parse float-like values from Excel cells."""
if value is None or str(value).strip() == '':
return None
try:
return float(str(value).replace(',', '.'))
except Exception:
return None
def _excel_list(value):
"""Parse list-like filter values from comma/semicolon/newline separated strings."""
if value is None:
return []
if isinstance(value, list):
return [sanitize_form_value(v) for v in value if str(v).strip()]
text = str(value).strip()
if not text:
return []
parts = re.split(r'[;,\n\|]+', text)
cleaned = [sanitize_form_value(p.strip()) for p in parts if p and p.strip()]
# Deduplicate while preserving order.
seen = set()
unique = []
for entry in cleaned:
if entry not in seen:
unique.append(entry)
seen.add(entry)
return unique
def _upload_excel_items(scope='inventory'):
"""Bulk import inventory/library items from Excel with validation-first workflow."""
if 'username' not in session:
flash('Nicht angemeldet.', 'error')
return redirect(url_for('login'))
if not us.check_admin(session['username']):
flash('Administratorrechte erforderlich.', 'error')
return redirect(url_for('home'))
is_library_scope = scope == 'library'
file_field = 'library_excel' if is_library_scope else 'inventory_excel'
fallback_route = 'library_admin' if is_library_scope else 'upload_admin'
if is_library_scope:
if not cfg.LIBRARY_MODULE_ENABLED:
flash('Bibliotheks-Modul ist deaktiviert.', 'error')
return redirect(url_for('home_admin'))
excel_file = request.files.get(file_field)
if not excel_file or not excel_file.filename:
flash('Bitte eine Excel-Datei auswählen.', 'error')
return redirect(url_for(fallback_route))
filename_lower = excel_file.filename.lower()
if not filename_lower.endswith('.xlsx'):
flash('Nur .xlsx Dateien werden unterstützt.', 'error')
return redirect(url_for(fallback_route))
try:
from openpyxl import load_workbook
except Exception:
flash('Excel-Import benötigt das Paket openpyxl. Bitte Abhängigkeiten aktualisieren.', 'error')
return redirect(url_for(fallback_route))
try:
workbook = load_workbook(excel_file, data_only=True, read_only=True)
sheet = workbook.active
except Exception as exc:
flash(f'Excel-Datei konnte nicht gelesen werden: {exc}', 'error')
return redirect(url_for(fallback_route))
header_row = next(sheet.iter_rows(min_row=1, max_row=1, values_only=True), None)
if not header_row:
flash('Die Excel-Datei enthält keine Kopfzeile.', 'error')
return redirect(url_for(fallback_route))
header_map = {}
for idx, raw_header in enumerate(header_row):
normalized = _normalize_excel_header(raw_header)
if normalized:
header_map[normalized] = idx
synonyms = {
'name': ['name', 'titel', 'artikel', 'item', 'bezeichnung'],
'ort': ['ort', 'location', 'standort', 'platz'],
'beschreibung': ['beschreibung', 'description', 'desc', 'details'],
'filter1': ['filter1', 'filter', 'fach', 'unterrichtsfach', 'kategorie1', 'category1'],
'filter2': ['filter2', 'jahrgang', 'jahrgangsstufe', 'klasse', 'kategorie2', 'category2'],
'filter3': ['filter3', 'bereich', 'gruppe', 'typ', 'kategorie3', 'category3'],
'anschaffungsjahr': ['anschaffungsjahr', 'jahr', 'year'],
'anschaffungskosten': ['anschaffungskosten', 'kosten', 'cost', 'preis', 'price'],
'code_4': ['code_4', 'code4', 'code', 'inventarnummer', 'inventar_nr', 'id_code'],
'reservierbar': ['reservierbar', 'reservable', 'bookable'],
'anzahl': ['anzahl', 'menge', 'quantity', 'count'],
'isbn': ['isbn'],
'item_type': ['item_type', 'typ', 'type'],
}
def col_index(key):
for candidate in synonyms.get(key, []):
normalized = _normalize_excel_header(candidate)
if normalized in header_map:
return header_map[normalized]
return None
required_columns = {'name': col_index('name'), 'ort': col_index('ort'), 'beschreibung': col_index('beschreibung')}
missing_required = [k for k, v in required_columns.items() if v is None]
if missing_required:
missing_label = ', '.join(missing_required)
flash(f'Pflichtspalten fehlen in Excel: {missing_label}', 'error')
return redirect(url_for(fallback_route))
mapped_indices = {
'name': col_index('name'),
'ort': col_index('ort'),
'beschreibung': col_index('beschreibung'),
'filter1': col_index('filter1'),
'filter2': col_index('filter2'),
'filter3': col_index('filter3'),
'anschaffungsjahr': col_index('anschaffungsjahr'),
'anschaffungskosten': col_index('anschaffungskosten'),
'code_4': col_index('code_4'),
'reservierbar': col_index('reservierbar'),
'anzahl': col_index('anzahl'),
'isbn': col_index('isbn'),
'item_type': col_index('item_type'),
}
validation_only = (request.form.get('excel_action') or '').strip().lower() == 'validate'
max_rows = 15000
max_generated_items = 50000
max_items_per_row = 1000
# Validation phase: parse + map + validate every row before any write.
planned_rows = []
validation_errors = []
validation_warnings = []
reserved_codes = set()
known_locations = set(it.get_predefined_locations())
def is_code_available(candidate_code):
return candidate_code not in reserved_codes and it.is_code_unique(candidate_code)
def resolve_unique_code(base_candidate):
if is_code_available(base_candidate):
reserved_codes.add(base_candidate)
return base_candidate
suffix = 1
while suffix <= 1000:
alt = f"{base_candidate}-{suffix}"
if is_code_available(alt):
reserved_codes.add(alt)
return alt
suffix += 1
return None
processed_rows = 0
planned_item_total = 0
row_limit_exceeded = False
for row_number, row_values in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2):
processed_rows += 1
if processed_rows > max_rows:
row_limit_exceeded = True
break
def val(key):
idx = mapped_indices.get(key)
if idx is None or idx >= len(row_values):
return None
return row_values[idx]
name = sanitize_form_value(val('name'))
ort = sanitize_form_value(val('ort'))
beschreibung = sanitize_form_value(val('beschreibung'))
if not name and not ort and not beschreibung:
# Fully empty row.
continue
row_errors = []
if not name or not ort or not beschreibung:
row_errors.append('Pflichtfeld Name/Ort/Beschreibung fehlt')
if ort and ort not in known_locations:
validation_warnings.append((row_number, f'Ort "{ort}" wird neu angelegt'))
filter1 = expand_filter_selection(_excel_list(val('filter1')), 1)
filter2 = expand_filter_selection(_excel_list(val('filter2')), 2)
filter3 = _excel_list(val('filter3'))
anschaffungsjahr = _excel_int(val('anschaffungsjahr'))
raw_year = val('anschaffungsjahr')
if raw_year not in (None, '') and anschaffungsjahr is None:
row_errors.append('Anschaffungsjahr ist kein gültiger Integer')
anschaffungskosten = _excel_float(val('anschaffungskosten'))
raw_cost = val('anschaffungskosten')
if raw_cost not in (None, '') and anschaffungskosten is None:
row_errors.append('Anschaffungskosten sind keine gültige Zahl')
reservierbar = _excel_bool(val('reservierbar'), default=True)
count = _excel_int(val('anzahl')) or 1
raw_count = val('anzahl')
if raw_count not in (None, '') and _excel_int(raw_count) is None:
row_errors.append('Anzahl ist kein gültiger Integer')
count = max(1, min(count, max_items_per_row))
raw_code = val('code_4')
base_code = sanitize_form_value(str(raw_code).strip()) if raw_code is not None and str(raw_code).strip() else None
item_isbn = ''
raw_isbn = val('isbn')
if raw_isbn:
item_isbn = normalize_and_validate_isbn(str(raw_isbn))
if not item_isbn:
row_errors.append('ISBN ist ungültig (erwartet ISBN-10/13)')
if is_library_scope and not item_isbn:
row_errors.append('Für den Bibliotheksimport ist eine gültige ISBN erforderlich')
raw_item_type = sanitize_form_value(str(val('item_type')).strip().lower()) if val('item_type') else ''
item_type = 'book' if is_library_scope else (raw_item_type or ('book' if item_isbn else 'general'))
planned_codes = []
if base_code:
for position in range(1, count + 1):
candidate = base_code if position == 1 else f"{base_code}-{position}"
code = resolve_unique_code(candidate)
if not code:
row_errors.append(f'Code konnte nicht eindeutig erzeugt werden ({candidate})')
break
planned_codes.append(code)
else:
planned_codes = [None] * count
if row_errors:
validation_errors.append((row_number, '; '.join(row_errors)))
continue
planned_rows.append({
'row_number': row_number,
'name': name,
'ort': ort,
'beschreibung': beschreibung,
'filter1': filter1,
'filter2': filter2,
'filter3': filter3,
'anschaffungsjahr': anschaffungsjahr,
'anschaffungskosten': anschaffungskosten,
'reservierbar': reservierbar,
'count': count,
'codes': planned_codes,
'isbn': item_isbn,
'item_type': item_type,
})
planned_item_total += count
if planned_item_total > max_generated_items:
row_limit_exceeded = True
break
if row_limit_exceeded:
flash(
f'Importgrenze überschritten. Maximal erlaubt: {max_rows} Zeilen und {max_generated_items} erzeugte Artikel pro Datei.',
'error'
)
return redirect(url_for(fallback_route))
if validation_errors:
details = '; '.join([f'Zeile {n}: {msg}' for n, msg in validation_errors[:15]])
flash(f'Validierung fehlgeschlagen ({len(validation_errors)} Zeilen). {details}', 'error')
return redirect(url_for(fallback_route))
if validation_only:
warning_text = ''
if validation_warnings:
warning_details = '; '.join([f'Zeile {n}: {msg}' for n, msg in validation_warnings[:10]])
warning_text = f' Hinweise: {warning_details}'
planned_count = sum(row['count'] for row in planned_rows)
flash(f'Validierung erfolgreich: {len(planned_rows)} Zeilen geprüft, {planned_count} Artikel würden importiert.{warning_text}', 'success')
return redirect(url_for(fallback_route))
# Import phase: only runs after successful validation.
created_total = 0
import_errors = []
for row in planned_rows:
if row['ort'] and row['ort'] not in known_locations:
it.add_predefined_location(row['ort'])
known_locations.add(row['ort'])
series_group_id = str(uuid.uuid4()) if row['count'] > 1 else None
row_created_ids = []
for position in range(1, row['count'] + 1):
parent_item_id = str(row_created_ids[0]) if row_created_ids else None
code = row['codes'][position - 1] if position - 1 < len(row['codes']) else None
item_id = it.add_item(
name=row['name'],
ort=row['ort'],
beschreibung=row['beschreibung'],
images=[],
filter=row['filter1'],
filter2=row['filter2'],
filter3=row['filter3'],
ansch_jahr=row['anschaffungsjahr'],
ansch_kost=row['anschaffungskosten'],
code_4=code,
reservierbar=row['reservierbar'],
series_group_id=series_group_id,
series_count=row['count'],
series_position=position,
is_grouped_sub_item=(position > 1),
parent_item_id=parent_item_id,
isbn=row['isbn'],
item_type=row['item_type'],
)
if not item_id:
import_errors.append((row['row_number'], 'Fehler beim Anlegen des Artikels'))
break
row_created_ids.append(item_id)
created_total += len(row_created_ids)
if created_total == 0:
details = '; '.join([f'Zeile {n}: {msg}' for n, msg in import_errors[:10]])
flash(f'Keine Artikel importiert. {details}', 'error')
return redirect(url_for(fallback_route))
if import_errors:
flash(f'Excel-Import abgeschlossen: {created_total} Artikel importiert, {len(import_errors)} Zeilen fehlgeschlagen.', 'warning')
else:
flash(f'Excel-Import erfolgreich: {created_total} Artikel importiert.', 'success')
return redirect(url_for('home_admin'))
@app.route('/upload_inventory_excel', methods=['POST'])
def upload_inventory_excel():
"""Bulk import for non-library inventory."""
return _upload_excel_items(scope='inventory')
@app.route('/upload_library_excel', methods=['POST'])
def upload_library_excel():
"""Bulk import dedicated to library items (ISBN required)."""
return _upload_excel_items(scope='library')
@app.route('/uploads/<filename>')
def uploaded_file(filename):
"""
@@ -1354,21 +1742,62 @@ def library_loans_admin():
@app.route('/api/library_items')
def api_library_items():
"""
API endpoint to fetch all library items (books, CDs, DVDs, media).
Returns JSON array suitable for table view.
API endpoint to fetch library items (books, CDs, DVDs, media).
Supports pagination via query params: offset, limit.
"""
if 'username' not in session:
return jsonify([]), 401
return jsonify({'items': []}), 401
offset_raw = request.args.get('offset', '0')
limit_raw = request.args.get('limit', '120')
try:
offset = max(0, int(offset_raw))
except (TypeError, ValueError):
offset = 0
try:
limit = int(limit_raw)
except (TypeError, ValueError):
limit = 120
limit = min(max(limit, 1), 500)
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
items_db = db['items']
ausleihungen_db = db['ausleihungen']
query = {
'ItemType': {'$in': ['book', 'cd', 'dvd', 'media']},
'Deleted': {'$ne': True}
}
projection = {
'Name': 1,
'Autor': 1,
'Author': 1,
'ISBN': 1,
'Code_4': 1,
'Code4': 1,
'ItemType': 1,
'Verfuegbar': 1,
'Condition': 1,
'HasDamage': 1,
'User': 1,
'Ort': 1,
'Beschreibung': 1,
'Image': 1
}
total_count = items_db.count_documents(query)
library_items = list(items_db.find({
'ItemType': {'$in': ['book', 'cd', 'dvd', 'media']}
}))
library_items = list(
items_db.find(query, projection)
.sort([('Name', 1), ('_id', 1)])
.skip(offset)
.limit(limit)
)
item_ids = [str(item.get('_id')) for item in library_items if item.get('_id')]
active_records = []
@@ -1394,6 +1823,8 @@ def api_library_items():
for item in library_items:
item_id = str(item['_id'])
item['_id'] = item_id
if item.get('Code4') in (None, '') and item.get('Code_4') not in (None, ''):
item['Code4'] = item.get('Code_4')
condition_value = str(item.get('Condition', '')).strip().lower()
has_damage = bool(item.get('HasDamage')) or condition_value == 'destroyed'
@@ -1407,8 +1838,16 @@ def api_library_items():
item['LibraryDisplayStatus'] = 'available'
item['BorrowedBy'] = active_user_by_item.get(item_id) or item.get('User', '')
return jsonify(library_items), 200
count = len(library_items)
return jsonify({
'items': library_items,
'offset': offset,
'limit': limit,
'count': count,
'total': total_count,
'has_more': (offset + count) < total_count
}), 200
except Exception as e:
print(f"Error fetching library items: {e}")
return jsonify({'error': str(e)}), 500
@@ -2494,6 +2933,7 @@ def logout():
@app.route('/get_items', methods=['GET'])
def get_items():
"""Return items plus merged favorites (session + DB) and per-item favorite flag."""
client = None
try:
username = session.get('username')
# Merge DB favorites into session if logged in
@@ -2507,14 +2947,40 @@ def get_items():
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')
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
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
items_col = db['items']
items_cur = items_col.find({
base_query = {
'IsGroupedSubItem': {'$ne': True},
'ItemType': {'$nin': LIBRARY_ITEM_TYPES},
'Deleted': {'$ne': True},
})
}
total_count = items_col.count_documents(base_query)
items_cur = items_col.find(base_query).sort([('Name', 1), ('_id', 1)])
if pagination_requested:
items_cur = items_cur.skip(offset).limit(limit)
items = []
for itm in items_cur:
item_id_str = str(itm['_id'])
@@ -2549,11 +3015,26 @@ def get_items():
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)
return jsonify({'items': items, 'favorites': list(favorites)})
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,
'has_more': pagination_requested and ((offset + count) < total_count)
})
except Exception as e:
return jsonify({'items': [], 'error': str(e)}), 500
finally:
if client:
client.close()
@app.route('/get_item/<id>')
@@ -3760,6 +4241,79 @@ def duplicate_item():
return jsonify({'success': False, 'message': 'Serverfehler beim Duplizieren'}), 500
def _soft_delete_item_groups(db, root_item_ids, username):
"""Soft-delete one or more item groups and their borrow records."""
now = datetime.datetime.now()
unique_group_item_ids = []
seen_ids = set()
for root_item_id in root_item_ids:
for group_item_id in it.get_group_item_ids(root_item_id):
if group_item_id not in seen_ids:
seen_ids.add(group_item_id)
unique_group_item_ids.append(group_item_id)
if not unique_group_item_ids:
return {
'success': False,
'message': 'Element nicht gefunden.',
'soft_deleted_items': 0,
'soft_deleted_borrows': 0,
'group_item_ids': [],
}
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,
}}
)
_append_audit_event(
db,
'inventory_item_soft_deleted',
{
'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,
}
)
return {
'success': delete_success,
'soft_deleted_items': soft_deleted_items,
'soft_deleted_borrows': borrow_result.modified_count,
'group_item_ids': unique_group_item_ids,
'message': 'OK' if delete_success else 'Fehler beim revisionssicheren Deaktivieren des Elements.',
}
@app.route('/delete_item/<id>', methods=['POST', 'GET'])
def delete_item(id):
"""
@@ -3794,57 +4348,14 @@ def delete_item(id):
flash('Element nicht gefunden.', 'error')
return redirect(url_for('home_admin'))
# GoBD hardening: keep files and records immutable, use soft-delete markers only.
delete_success = True
soft_deleted_items = 0
soft_deleted_borrows = 0
now = datetime.datetime.now()
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
for group_item_id in group_item_ids:
result = db['items'].update_one(
{'_id': ObjectId(group_item_id), 'Deleted': {'$ne': True}},
{'$set': {
'Deleted': True,
'DeletedAt': now,
'DeletedBy': session.get('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': group_item_ids},
'Status': {'$ne': 'deleted'}
},
{'$set': {
'Status': 'deleted',
'DeletedAt': now,
'DeletedBy': session.get('username', ''),
'LastUpdated': now,
}}
)
soft_deleted_borrows = borrow_result.modified_count
_append_audit_event(
db,
'inventory_item_soft_deleted',
{
'root_item_id': id,
'group_item_ids': group_item_ids,
'soft_deleted_items': soft_deleted_items,
'soft_deleted_borrow_records': soft_deleted_borrows,
}
)
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))
except Exception as e:
app.logger.error(f"Error during soft-delete for item group {id}: {str(e)}")
delete_success = False
@@ -3860,6 +4371,52 @@ def delete_item(id):
return redirect(url_for('home_admin'))
@app.route('/bulk_delete_items', methods=['POST'])
def bulk_delete_items():
"""Soft-delete multiple selected item groups in one request."""
if 'username' not in session:
return jsonify({'success': False, 'message': 'Nicht angemeldet'}), 401
if not us.check_admin(session['username']):
return jsonify({'success': False, 'message': 'Administratorrechte erforderlich'}), 403
payload = request.get_json(silent=True) or {}
item_ids = payload.get('item_ids') or request.form.getlist('item_ids')
if isinstance(item_ids, str):
item_ids = [item_ids]
item_ids = [str(item_id).strip() for item_id in item_ids if str(item_id).strip()]
if not item_ids:
return jsonify({'success': False, 'message': 'Keine Elemente ausgewählt.'}), 400
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
result = _soft_delete_item_groups(db, item_ids, session.get('username', ''))
if not result.get('success'):
return jsonify({
'success': False,
'message': result.get('message', 'Fehler beim Löschen.'),
'deleted_items': result.get('soft_deleted_items', 0),
'deleted_borrows': result.get('soft_deleted_borrows', 0),
}), 400
return jsonify({
'success': True,
'message': f"{result.get('soft_deleted_items', 0)} Elemente revisionssicher deaktiviert.",
'deleted_items': result.get('soft_deleted_items', 0),
'deleted_borrows': result.get('soft_deleted_borrows', 0),
'group_item_ids': result.get('group_item_ids', []),
})
except Exception as e:
app.logger.error(f"Error during bulk delete: {str(e)}")
return jsonify({'success': False, 'message': 'Fehler beim Sammellöschen.'}), 500
finally:
if client:
client.close()
@app.route('/edit_item/<id>', methods=['POST'])
def edit_item(id):
"""
+1 -1
View File
@@ -25,7 +25,6 @@ Sammlungsstruktur:
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
'''
from pymongo import MongoClient
from bson.objectid import ObjectId
import datetime
import pytz
@@ -34,6 +33,7 @@ import os
import json
import shutil
import settings as cfg
from settings import MongoClient
# Add this helper function after imports
def ensure_timezone_aware(dt):
+1 -1
View File
@@ -26,10 +26,10 @@ Collection Structure:
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
'''
from pymongo import MongoClient
from bson.objectid import ObjectId
import datetime
import settings as cfg
from settings import MongoClient
LIBRARY_ITEM_TYPES = ('book', 'cd', 'dvd', 'media')
+2 -1
View File
@@ -10,4 +10,5 @@ python-dateutil
pytz
requests
reportlab
python-barcode
python-barcode
openpyxl
+19
View File
@@ -12,6 +12,7 @@ defaults for the web application and helper modules.
"""
import os
import json
from pymongo import MongoClient as _PyMongoClient
# Base directory of this Web package
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -173,3 +174,21 @@ if not os.path.isabs(BACKUP_FOLDER):
BACKUP_FOLDER = os.path.join(PROJECT_ROOT, BACKUP_FOLDER)
if not os.path.isabs(LOGS_FOLDER):
LOGS_FOLDER = os.path.join(PROJECT_ROOT, LOGS_FOLDER)
def MongoClient(*args, **kwargs):
"""Return a lightweight MongoDB client configured from this settings module."""
client_kwargs = {
'maxPoolSize': 10,
'minPoolSize': 0,
'connectTimeoutMS': 5000,
'serverSelectionTimeoutMS': 5000,
'retryWrites': True,
}
client_kwargs.update(kwargs)
# Preserve caller-provided positional host/port arguments.
# If none are passed, use configured defaults.
if args:
return _PyMongoClient(*args, **client_kwargs)
return _PyMongoClient(MONGODB_HOST, MONGODB_PORT, **client_kwargs)
+164 -34
View File
@@ -290,6 +290,20 @@
color: #666;
}
.library-load-row {
margin-top: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.library-load-hint {
font-size: 0.9em;
color: #6b7280;
}
/* Modal styles */
.modal {
display: none;
@@ -501,6 +515,10 @@
<!-- Items will be loaded here -->
</tbody>
</table>
<div id="loadMoreRow" class="library-load-row" style="display:none;">
<span id="loadInfo" class="library-load-hint"></span>
<button id="loadMoreBtn" class="button" type="button">Mehr laden</button>
</div>
</div>
<!-- Empty state -->
@@ -523,6 +541,24 @@
// State
let libraryItems = [];
let filteredItems = [];
let visibleItems = [];
let currentSearchTerm = '';
let activeFilters = {
author: '',
isbn: '',
type: '',
status: ''
};
let pagingState = {
offset: 0,
total: 0,
hasMore: true,
loading: false
};
const API_PAGE_SIZE = 120;
const INITIAL_RENDER_COUNT = 120;
const RENDER_BATCH_COUNT = 120;
let renderedCount = INITIAL_RENDER_COUNT;
let filterPanelOpen = false;
let scannerInstance = null;
let scannerRunning = false;
@@ -531,32 +567,124 @@
let lastScanAt = 0;
const canEditLibraryItems = (document.getElementById('libraryTableContainer')?.dataset.canEdit === '1');
// Load items
async function fetchLibraryPage(offset, limit) {
const response = await fetch(`/api/library_items?offset=${offset}&limit=${limit}`);
if (!response.ok) {
throw new Error('Failed to load library items');
}
return response.json();
}
// Load items progressively: first page immediately, rest in background
async function loadLibraryItems() {
if (pagingState.loading) return;
pagingState.loading = true;
try {
const response = await fetch('/api/library_items');
if (!response.ok) throw new Error('Failed to load library items');
libraryItems = await response.json();
displayItems(libraryItems);
const firstPage = await fetchLibraryPage(0, API_PAGE_SIZE);
libraryItems = firstPage.items || [];
pagingState.offset = (firstPage.offset || 0) + (firstPage.count || libraryItems.length);
pagingState.total = firstPage.total || libraryItems.length;
pagingState.hasMore = Boolean(firstPage.has_more);
applyFiltersAndSearch(true);
if (pagingState.hasMore) {
loadRemainingLibraryItemsInBackground();
}
} catch (error) {
console.error('Error loading library items:', error);
document.getElementById('itemsTableBody').innerHTML = '<tr><td colspan="6" style="text-align:center; color:#999;">Fehler beim Laden der Bibliothekselemente.</td></tr>';
} finally {
pagingState.loading = false;
}
}
// Display items in table
function displayItems(items) {
async function loadRemainingLibraryItemsInBackground() {
while (pagingState.hasMore) {
try {
const page = await fetchLibraryPage(pagingState.offset, API_PAGE_SIZE);
const nextItems = page.items || [];
if (nextItems.length === 0) {
pagingState.hasMore = false;
break;
}
libraryItems = libraryItems.concat(nextItems);
pagingState.offset = (page.offset || pagingState.offset) + (page.count || nextItems.length);
pagingState.total = page.total || pagingState.total;
pagingState.hasMore = Boolean(page.has_more);
// Keep the current view reactive while avoiding a full rerender burst.
applyFiltersAndSearch(false);
if ('requestIdleCallback' in window) {
await new Promise(resolve => requestIdleCallback(() => resolve(), { timeout: 250 }));
} else {
await new Promise(resolve => setTimeout(resolve, 0));
}
} catch (error) {
console.error('Error loading additional library items:', error);
break;
}
}
}
function filterItems(items) {
const author = activeFilters.author;
const isbn = activeFilters.isbn;
const type = activeFilters.type;
const status = activeFilters.status;
return items.filter(item => {
const authorMatch = !author || (item.Autor || item.Author || '').toLowerCase().includes(author);
const isbnMatch = !isbn || (item.ISBN || item.Code_4 || item.Code4 || '').toLowerCase().includes(isbn);
const typeMatch = !type || (item.ItemType || 'book') === type;
const statusKey = item.LibraryDisplayStatus || (item.Verfuegbar === false ? 'borrowed' : 'available');
const statusMatch = !status || statusKey === status;
return authorMatch && isbnMatch && typeMatch && statusMatch;
});
}
function applySearch(items) {
if (!currentSearchTerm) return items;
return items.filter(item =>
(item.Name || '').toLowerCase().includes(currentSearchTerm) ||
(item.Autor || item.Author || '').toLowerCase().includes(currentSearchTerm) ||
(item.ISBN || item.Code_4 || item.Code4 || '').toLowerCase().includes(currentSearchTerm)
);
}
function applyFiltersAndSearch(resetRenderCount) {
const filteredByPanel = filterItems(libraryItems);
filteredItems = applySearch(filteredByPanel);
if (resetRenderCount) {
renderedCount = Math.min(INITIAL_RENDER_COUNT, filteredItems.length);
} else {
renderedCount = Math.min(Math.max(renderedCount, INITIAL_RENDER_COUNT), filteredItems.length || INITIAL_RENDER_COUNT);
}
visibleItems = filteredItems;
renderItems();
}
// Display current view with incremental rendering
function renderItems() {
const tbody = document.getElementById('itemsTableBody');
const emptyState = document.getElementById('emptyState');
const loadMoreRow = document.getElementById('loadMoreRow');
const loadInfo = document.getElementById('loadInfo');
const loadMoreBtn = document.getElementById('loadMoreBtn');
if (items.length === 0) {
if (visibleItems.length === 0) {
tbody.innerHTML = '';
emptyState.style.display = 'block';
loadMoreRow.style.display = 'none';
return;
}
emptyState.style.display = 'none';
tbody.innerHTML = items.map(item => `
const rowsToRender = visibleItems.slice(0, renderedCount);
tbody.innerHTML = rowsToRender.map(item => `
${(() => {
const statusKey = item.LibraryDisplayStatus || (item.Verfuegbar === false ? 'borrowed' : 'available');
const statusClass = statusKey === 'damaged' ? 'status-damaged' : (statusKey === 'borrowed' ? 'status-borrowed' : 'status-available');
@@ -567,7 +695,7 @@
<tr>
<td class="table-title">${escapeHtml(item.Name || 'Untitled')}</td>
<td>${escapeHtml(item.Autor || item.Author || '-')}</td>
<td>${escapeHtml(item.ISBN || item.Code4 || '-')}</td>
<td>${escapeHtml(item.ISBN || item.Code_4 || item.Code4 || '-')}</td>
<td>${getItemTypeLabel(item.ItemType || 'book')}</td>
<td>
<span class="table-status ${statusClass}">
@@ -585,6 +713,18 @@
`;
})()}
`).join('');
const canLoadMoreRows = renderedCount < visibleItems.length;
if (canLoadMoreRows) {
loadMoreRow.style.display = 'flex';
loadMoreBtn.style.display = 'inline-block';
loadInfo.textContent = `${renderedCount} von ${visibleItems.length} angezeigt`;
} else {
const backgroundHint = pagingState.hasMore ? `Lade weitere Medien im Hintergrund (${libraryItems.length}/${pagingState.total || '?'})...` : `${visibleItems.length} Treffer`;
loadMoreRow.style.display = 'flex';
loadMoreBtn.style.display = 'none';
loadInfo.textContent = backgroundHint;
}
}
// Filter toggle
@@ -598,21 +738,11 @@
// Apply filters
document.getElementById('applyFilterBtn').addEventListener('click', () => {
const author = document.getElementById('filterAuthor').value.toLowerCase();
const isbn = document.getElementById('filterISBN').value.toLowerCase();
const type = document.getElementById('filterType').value;
const status = document.getElementById('filterStatus').value;
filteredItems = libraryItems.filter(item => {
const authorMatch = !author || (item.Autor || item.Author || '').toLowerCase().includes(author);
const isbnMatch = !isbn || (item.ISBN || item.Code4 || '').toLowerCase().includes(isbn);
const typeMatch = !type || (item.ItemType || 'book') === type;
const statusKey = item.LibraryDisplayStatus || (item.Verfuegbar === false ? 'borrowed' : 'available');
const statusMatch = !status || statusKey === status;
return authorMatch && isbnMatch && typeMatch && statusMatch;
});
displayItems(filteredItems);
activeFilters.author = document.getElementById('filterAuthor').value.toLowerCase();
activeFilters.isbn = document.getElementById('filterISBN').value.toLowerCase();
activeFilters.type = document.getElementById('filterType').value;
activeFilters.status = document.getElementById('filterStatus').value;
applyFiltersAndSearch(true);
});
// Clear filters
@@ -621,19 +751,19 @@
document.getElementById('filterISBN').value = '';
document.getElementById('filterType').value = '';
document.getElementById('filterStatus').value = '';
displayItems(libraryItems);
activeFilters = { author: '', isbn: '', type: '', status: '' };
applyFiltersAndSearch(true);
});
// Search input
document.getElementById('librarySearch').addEventListener('input', (e) => {
const searchTerm = e.target.value.toLowerCase();
const toSearch = filteredItems.length > 0 ? filteredItems : libraryItems;
const results = toSearch.filter(item =>
(item.Name || '').toLowerCase().includes(searchTerm) ||
(item.Autor || item.Author || '').toLowerCase().includes(searchTerm) ||
(item.ISBN || item.Code4 || '').toLowerCase().includes(searchTerm)
);
displayItems(results);
currentSearchTerm = (e.target.value || '').toLowerCase();
applyFiltersAndSearch(true);
});
document.getElementById('loadMoreBtn').addEventListener('click', () => {
renderedCount = Math.min(renderedCount + RENDER_BATCH_COUNT, visibleItems.length);
renderItems();
});
// Helper functions
+118 -9
View File
@@ -724,8 +724,34 @@
// Add this line to define allItems globally
let allItems = [];
function loadItems() {
fetch("{{ url_for('get_items') }}")
const MAIN_ITEMS_PAGE_SIZE = 120;
let mainItemsNextOffset = 0;
let mainItemsHasMore = false;
let mainItemsLoadingMore = false;
let mainItemsObserver = null;
let mainItemsSentinel = null;
let mainItemsScrollPrefetchBound = false;
function maybePrefetchMainItems() {
const itemsContainer = document.querySelector('#items-container');
if (!itemsContainer || !mainItemsHasMore || mainItemsLoadingMore) {
return;
}
const distanceToEnd = itemsContainer.scrollWidth - (itemsContainer.scrollLeft + itemsContainer.clientWidth);
const prefetchThreshold = Math.max(260, itemsContainer.clientWidth * 1.4);
if (distanceToEnd > prefetchThreshold) {
return;
}
mainItemsLoadingMore = true;
loadItems(mainItemsNextOffset, true).finally(() => {
mainItemsLoadingMore = false;
});
}
function loadItems(offset = 0, append = false) {
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ITEMS_PAGE_SIZE}`)
.then(response => response.json())
.then(data => {
const itemsContainer = document.querySelector('#items-container');
@@ -735,27 +761,39 @@
const filter3Values = new Set();
// Store all items data globally for filter rebuilding
allItems = data.items || [];
const pageItems = data.items || [];
allItems = append ? allItems.concat(pageItems) : pageItems;
// Clear the container first
itemsContainer.innerHTML = '';
if (!append) {
itemsContainer.innerHTML = '';
}
if (!data.items || data.items.length === 0) {
if (!append && (!pageItems || pageItems.length === 0)) {
itemsContainer.innerHTML = '<div class="no-items-message">Keine Objekte gefunden</div>';
return;
}
// Sort items by name in ascending order (A-Z)
data.items.sort((a, b) => {
pageItems.sort((a, b) => {
const nameA = a.Name ? a.Name.toLowerCase() : '';
const nameB = b.Name ? b.Name.toLowerCase() : '';
return nameA.localeCompare(nameB); // Ascending order
});
allItems.forEach(itemForFilters => {
const f1 = Array.isArray(itemForFilters.Filter) ? itemForFilters.Filter : (itemForFilters.Filter ? [itemForFilters.Filter] : []);
const f2 = Array.isArray(itemForFilters.Filter2) ? itemForFilters.Filter2 : (itemForFilters.Filter2 ? [itemForFilters.Filter2] : []);
const f3 = Array.isArray(itemForFilters.Filter3) ? itemForFilters.Filter3 : (itemForFilters.Filter3 ? [itemForFilters.Filter3] : []);
f1.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter1Values.add(value); });
f2.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter2Values.add(value); });
f3.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter3Values.add(value); });
});
const favoriteIds = new Set(data.favorites || []);
window.currentFavorites = favoriteIds;
try { sessionStorage.setItem('favoritesCache', JSON.stringify(Array.from(favoriteIds))); } catch(e){}
data.items.forEach(item => {
pageItems.forEach(item => {
try {
const card = document.createElement('div');
card.classList.add('item-card');
@@ -995,8 +1033,10 @@
populateFilterOptions('filter2-options', [...filter2Values].sort(), 2);
populateFilterOptions('filter3-options', [...filter3Values].sort(), 3);
// Start display from leftmost position (first card, which is now Z)
itemsContainer.scrollLeft = 0;
// Start display from leftmost position on full reload only.
if (!append) {
itemsContainer.scrollLeft = 0;
}
// Apply filters
applyFilters();
@@ -1011,15 +1051,84 @@
if (activeFilters.filter1.length > 0 || activeFilters.filter2.length > 0) {
rebuildFilter3Options();
}
mainItemsNextOffset = (data.offset || offset) + (data.count || pageItems.length);
mainItemsHasMore = Boolean(data.has_more);
setupMainItemsLazyLoading();
maybePrefetchMainItems();
})
.catch(error => {
console.error('Error fetching items:', error);
if (append) {
return;
}
const itemsContainer = document.querySelector('#items-container');
itemsContainer.innerHTML =
'<div class="error-message">Fehler beim Laden der Objekte. Bitte versuchen Sie es später erneut.</div>';
});
}
function ensureMainItemsSentinel() {
const itemsContainer = document.querySelector('#items-container');
if (!itemsContainer) return null;
if (!mainItemsSentinel) {
mainItemsSentinel = document.createElement('div');
mainItemsSentinel.id = 'main-items-sentinel';
mainItemsSentinel.style.flex = '0 0 1px';
mainItemsSentinel.style.width = '1px';
mainItemsSentinel.style.minWidth = '1px';
mainItemsSentinel.style.height = '1px';
mainItemsSentinel.style.pointerEvents = 'none';
}
if (!mainItemsSentinel.parentNode) {
itemsContainer.appendChild(mainItemsSentinel);
} else {
itemsContainer.appendChild(mainItemsSentinel);
}
return itemsContainer;
}
function setupMainItemsLazyLoading() {
const itemsContainer = ensureMainItemsSentinel();
if (!itemsContainer) return;
if (mainItemsObserver) {
mainItemsObserver.disconnect();
mainItemsObserver = null;
}
if (!mainItemsScrollPrefetchBound) {
itemsContainer.addEventListener('scroll', maybePrefetchMainItems, { passive: true });
mainItemsScrollPrefetchBound = true;
}
if (!mainItemsHasMore) return;
mainItemsObserver = new IntersectionObserver(entries => {
if (!entries.some(entry => entry.isIntersecting)) {
return;
}
if (mainItemsLoadingMore || !mainItemsHasMore) {
return;
}
mainItemsLoadingMore = true;
loadItems(mainItemsNextOffset, true).finally(() => {
mainItemsLoadingMore = false;
});
}, {
root: itemsContainer,
threshold: 0.15,
rootMargin: '0px 900px 0px 0px'
});
mainItemsObserver.observe(mainItemsSentinel);
}
function searchByCode() {
const searchInput = document.getElementById('code-search');
const rawSearchTerm = searchInput.value;
+415 -9
View File
@@ -26,6 +26,106 @@
font-weight: 600;
}
.bulk-delete-inline-wrap {
display: inline-flex;
}
.bulk-delete-drawer-toggle {
position: static;
transform: none;
z-index: 2;
}
.bulk-delete-drawer-toggle .drawer-icon {
display: inline-block;
margin-right: 4px;
}
.bulk-delete-drawer-toggle:hover {
box-shadow: 0 10px 20px rgba(13, 110, 253, 0.3);
}
.bulk-delete-drawer-toggle:active {
transform: scale(0.98);
}
.bulk-delete-drawer {
display: none;
width: 100%;
max-width: 980px;
margin: 0 auto 18px;
border: 1px solid #dbe3ee;
background: #ffffff;
border-radius: 10px;
padding: 14px;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
}
.bulk-delete-drawer.open {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 14px;
}
.bulk-delete-content {
flex: 1 1 380px;
}
.bulk-delete-drawer strong {
display: block;
margin-bottom: 2px;
}
.bulk-delete-summary {
color: #334155;
font-size: 0.92rem;
margin-top: 4px;
}
.bulk-delete-drawer small {
color: #64748b;
display: block;
margin-top: 2px;
line-height: 1.35;
}
.bulk-delete-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 4px;
align-items: center;
flex: 0 1 auto;
}
.bulk-delete-actions button {
border: 1px solid #dbe3ee;
border-radius: 8px;
padding: 9px 12px;
cursor: pointer;
background: #f8fafc;
color: #1e293b;
font-weight: 600;
text-align: center;
}
.bulk-delete-actions button:hover {
background: #eef4ff;
border-color: #bfdbfe;
}
.bulk-delete-actions .danger {
background: #dc3545;
color: #fff;
border-color: #c82333;
}
.bulk-delete-actions .danger:hover {
background: #c82333;
border-color: #b21f2d;
}
/* Filter styles */
.filter-container {
display: flex;
@@ -237,6 +337,12 @@
position: relative;
}
.item-card.bulk-selected {
border-color: #dc3545;
box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.18), 0 0 10px rgba(0, 0, 0, 0.1);
background: #fffdfd;
}
.item-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
@@ -257,6 +363,45 @@
margin: 10px 0;
}
.bulk-delete-row {
display: flex;
justify-content: flex-start;
align-items: center;
margin-bottom: 8px;
gap: 8px;
}
.bulk-delete-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.9rem;
color: #475569;
cursor: pointer;
user-select: none;
}
.bulk-delete-toggle input {
width: 16px;
height: 16px;
accent-color: #dc3545;
cursor: pointer;
}
@media (max-width: 768px) {
.bulk-delete-drawer.open {
flex-direction: column;
}
.bulk-delete-actions {
width: 100%;
}
.bulk-delete-actions button {
width: 100%;
}
}
.item-card .item-image {
width: 100%;
height: auto;
@@ -2109,9 +2254,26 @@ document.addEventListener('DOMContentLoaded', ()=>{
<button id="toggle-favorites-view" class="view-toggle-btn" title="Nur Merkliste anzeigen">
<span id="favorites-view-icon">🔖</span>
</button>
<div class="bulk-delete-inline-wrap">
<button type="button" id="bulk-delete-drawer-toggle" class="view-toggle-btn bulk-delete-drawer-toggle" aria-expanded="false" title="Massenlöschung" onclick="toggleBulkDeleteDrawer()">
<span class="drawer-icon"></span>
</button>
</div>
</div>
</h1>
<div id="items-indicator" class="items-indicator">Objekte im System: 0</div>
<div id="bulk-delete-drawer" class="bulk-delete-drawer" aria-hidden="true">
<div class="bulk-delete-content">
<strong>Massenlöschung nach Kriterien</strong>
<div class="bulk-delete-summary" id="bulk-delete-summary">0 Elemente ausgewählt</div>
<small>Filter zuerst setzen, dann alle sichtbaren Elemente markieren oder einzelne Karten per Checkbox auswählen.</small>
</div>
<div class="bulk-delete-actions">
<button type="button" onclick="selectVisibleItems()">Alle sichtbaren auswählen</button>
<button type="button" onclick="clearBulkSelection()">Auswahl leeren</button>
<button type="button" id="bulk-delete-button" class="danger" onclick="deleteSelectedItems()" disabled>Auswahl löschen</button>
</div>
</div>
<div class="filter-container">
<div class="filter-group">
<div class="filter-header">
@@ -2492,6 +2654,8 @@ document.addEventListener('DOMContentLoaded', ()=>{
let descSearchIds = null; // Set of matching IDs or null when disabled
let currentUsername = '';
let allItems = []; // Cache for all items
let bulkDeleteSelection = new Set();
let bulkDeleteDrawerOpen = false;
const studentCardsModuleEnabled = {{ 'true' if student_cards_module_enabled else 'false' }};
const studentDefaultBorrowDays = {{ student_default_borrow_days|default(14) }};
const studentMaxBorrowDays = {{ student_max_borrow_days|default(365) }};
@@ -2822,6 +2986,134 @@ document.addEventListener('DOMContentLoaded', ()=>{
filter3: []
};
}
function updateBulkDeleteSummary() {
const summary = document.getElementById('bulk-delete-summary');
const button = document.getElementById('bulk-delete-button');
const count = bulkDeleteSelection.size;
if (summary) {
summary.textContent = `${count} ${count === 1 ? 'Element' : 'Elemente'} ausgewählt`;
}
if (button) {
button.disabled = count === 0;
button.textContent = count === 0 ? 'Auswahl löschen' : `Auswahl löschen (${count})`;
}
}
function setBulkDeleteDrawer(open) {
bulkDeleteDrawerOpen = Boolean(open);
const drawer = document.getElementById('bulk-delete-drawer');
const toggle = document.getElementById('bulk-delete-drawer-toggle');
if (drawer) {
drawer.classList.toggle('open', bulkDeleteDrawerOpen);
drawer.setAttribute('aria-hidden', bulkDeleteDrawerOpen ? 'false' : 'true');
}
if (toggle) {
toggle.setAttribute('aria-expanded', bulkDeleteDrawerOpen ? 'true' : 'false');
toggle.innerHTML = bulkDeleteDrawerOpen ? '<span class="drawer-icon">✕</span>' : '<span class="drawer-icon">⚙</span>';
toggle.title = bulkDeleteDrawerOpen ? 'Massenlöschung schließen' : 'Massenlöschung öffnen';
}
}
function toggleBulkDeleteDrawer() {
setBulkDeleteDrawer(!bulkDeleteDrawerOpen);
}
function setBulkDeleteSelection(itemId, checked) {
const normalizedId = String(itemId || '').trim();
if (!normalizedId) {
return;
}
if (checked) {
bulkDeleteSelection.add(normalizedId);
} else {
bulkDeleteSelection.delete(normalizedId);
}
document.querySelectorAll(`.item-card[data-item-id='${normalizedId}']`).forEach(card => {
card.classList.toggle('bulk-selected', checked);
const checkbox = card.querySelector('.bulk-delete-checkbox');
if (checkbox && checkbox.checked !== checked) {
checkbox.checked = checked;
}
});
updateBulkDeleteSummary();
}
function selectVisibleItems() {
document.querySelectorAll('.item-card').forEach(card => {
if (card.style.display === 'none') {
return;
}
const itemId = String(card.dataset.itemId || '').trim();
if (!itemId) {
return;
}
bulkDeleteSelection.add(itemId);
card.classList.add('bulk-selected');
const checkbox = card.querySelector('.bulk-delete-checkbox');
if (checkbox) {
checkbox.checked = true;
}
});
updateBulkDeleteSummary();
}
function clearBulkSelection() {
bulkDeleteSelection.clear();
document.querySelectorAll('.item-card.bulk-selected').forEach(card => {
card.classList.remove('bulk-selected');
const checkbox = card.querySelector('.bulk-delete-checkbox');
if (checkbox) {
checkbox.checked = false;
}
});
updateBulkDeleteSummary();
}
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape' && bulkDeleteDrawerOpen) {
setBulkDeleteDrawer(false);
}
});
function deleteSelectedItems() {
const selectedIds = [...bulkDeleteSelection];
if (selectedIds.length === 0) {
alert('Bitte zuerst mindestens ein Element auswählen.');
return;
}
if (!confirm(`Wirklich ${selectedIds.length} ausgewählte Elemente revisionssicher deaktivieren?`)) {
return;
}
fetch('{{ url_for('bulk_delete_items') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({ item_ids: selectedIds })
})
.then(async response => {
const data = await response.json().catch(() => ({}));
if (!response.ok || !data.success) {
throw new Error(data.message || 'Fehler beim Sammellöschen.');
}
clearBulkSelection();
alert(data.message || 'Auswahl gelöscht.');
loadItems();
})
.catch(error => {
alert(error.message || 'Fehler beim Sammellöschen.');
});
}
// Load predefined filter values for dropdowns
function loadPredefinedFilterValues(filterNumber) {
@@ -2963,8 +3255,34 @@ document.addEventListener('DOMContentLoaded', ()=>{
});
// Function to load items from server
function loadItems() {
fetch("{{ url_for('get_items') }}")
const MAIN_ADMIN_ITEMS_PAGE_SIZE = 120;
let mainAdminItemsNextOffset = 0;
let mainAdminItemsHasMore = false;
let mainAdminItemsLoadingMore = false;
let mainAdminItemsObserver = null;
let mainAdminItemsSentinel = null;
let mainAdminItemsScrollPrefetchBound = false;
function maybePrefetchMainAdminItems() {
const itemsContainer = document.querySelector('#items-container');
if (!itemsContainer || !mainAdminItemsHasMore || mainAdminItemsLoadingMore) {
return;
}
const distanceToEnd = itemsContainer.scrollWidth - (itemsContainer.scrollLeft + itemsContainer.clientWidth);
const prefetchThreshold = Math.max(260, itemsContainer.clientWidth * 1.4);
if (distanceToEnd > prefetchThreshold) {
return;
}
mainAdminItemsLoadingMore = true;
loadItems(mainAdminItemsNextOffset, true).finally(() => {
mainAdminItemsLoadingMore = false;
});
}
function loadItems(offset = 0, append = false) {
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ADMIN_ITEMS_PAGE_SIZE}`)
.then(response => response.json())
.then(data => {
const itemsContainer = document.querySelector('#items-container');
@@ -2974,9 +3292,12 @@ document.addEventListener('DOMContentLoaded', ()=>{
const filter1Values = new Set();
const filter2Values = new Set();
const filter3Values = new Set();
allItems = data.items || [];
itemsContainer.innerHTML = '';
if (!data.items || data.items.length === 0) {
const pageItems = data.items || [];
allItems = append ? allItems.concat(pageItems) : pageItems;
if (!append) {
itemsContainer.innerHTML = '';
}
if (!append && (!pageItems || pageItems.length === 0)) {
itemsContainer.innerHTML = `
<div class="no-items-message">
<div>Keine Objekte gefunden</div>
@@ -2989,15 +3310,27 @@ document.addEventListener('DOMContentLoaded', ()=>{
return;
}
if (itemsIndicator) {
itemsIndicator.textContent = `Objekte im System: ${data.items.length}`;
const shownCount = allItems.length;
const totalCount = Number(data.total || shownCount);
itemsIndicator.textContent = `Objekte im System: ${shownCount}${data.has_more ? ` / ${totalCount} (lädt...)` : ''}`;
}
data.items.sort((a, b) => {
pageItems.sort((a, b) => {
const nameA = a.Name ? a.Name.toLowerCase() : '';
const nameB = a.Name ? a.Name.toLowerCase() : '';
return nameA.localeCompare(nameB);
});
allItems.forEach(itemForFilters => {
const f1 = Array.isArray(itemForFilters.Filter) ? itemForFilters.Filter : (itemForFilters.Filter ? [itemForFilters.Filter] : []);
const f2 = Array.isArray(itemForFilters.Filter2) ? itemForFilters.Filter2 : (itemForFilters.Filter2 ? [itemForFilters.Filter2] : []);
const f3 = Array.isArray(itemForFilters.Filter3) ? itemForFilters.Filter3 : (itemForFilters.Filter3 ? [itemForFilters.Filter3] : []);
f1.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter1Values.add(value); });
f2.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter2Values.add(value); });
f3.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter3Values.add(value); });
});
const favoriteIds = new Set(data.favorites || []);
data.items.forEach(item => {
pageItems.forEach(item => {
try {
const card = document.createElement('div');
card.classList.add('item-card');
@@ -3148,6 +3481,12 @@ document.addEventListener('DOMContentLoaded', ()=>{
${appointmentBadge}
</div>
<div class="actions">
<div class="bulk-delete-row">
<label class="bulk-delete-toggle">
<input type="checkbox" class="bulk-delete-checkbox" data-item-id="${item._id}" ${bulkDeleteSelection.has(String(item._id)) ? 'checked' : ''} onchange="setBulkDeleteSelection('${item._id}', this.checked)">
Für Löschung markieren
</label>
</div>
${isAvailableForBorrow && !item.BlockedNow ?
`<form method="POST" action="{{ url_for('ausleihen', id='') }}${item._id}">
${isGroupedItem ? `
@@ -3223,8 +3562,11 @@ document.addEventListener('DOMContentLoaded', ()=>{
populateFilterOptions('filter2-options', [...filter2Values].sort(), 2);
populateFilterOptions('filter3-options', [...filter3Values].sort(), 3);
itemsContainer.scrollLeft = 0;
if (!append) {
itemsContainer.scrollLeft = 0;
}
applyFilters();
updateBulkDeleteSummary();
// Setup proper image display for all cards
setupCardImagesDisplay();
@@ -3237,9 +3579,17 @@ document.addEventListener('DOMContentLoaded', ()=>{
rebuildFilter3Options();
}
/* favorites render count removed */
mainAdminItemsNextOffset = (data.offset || offset) + (data.count || pageItems.length);
mainAdminItemsHasMore = Boolean(data.has_more);
setupMainAdminItemsLazyLoading();
maybePrefetchMainAdminItems();
})
.catch(error => {
console.error('Error fetching items:', error);
if (append) {
return;
}
const itemsContainer = document.querySelector('#items-container');
const itemsIndicator = document.getElementById('items-indicator');
itemsContainer.innerHTML =
@@ -3250,6 +3600,62 @@ document.addEventListener('DOMContentLoaded', ()=>{
});
}
function ensureMainAdminItemsSentinel() {
const itemsContainer = document.querySelector('#items-container');
if (!itemsContainer) return null;
if (!mainAdminItemsSentinel) {
mainAdminItemsSentinel = document.createElement('div');
mainAdminItemsSentinel.id = 'main-admin-items-sentinel';
mainAdminItemsSentinel.style.flex = '0 0 1px';
mainAdminItemsSentinel.style.width = '1px';
mainAdminItemsSentinel.style.minWidth = '1px';
mainAdminItemsSentinel.style.height = '1px';
mainAdminItemsSentinel.style.pointerEvents = 'none';
}
itemsContainer.appendChild(mainAdminItemsSentinel);
return itemsContainer;
}
function setupMainAdminItemsLazyLoading() {
const itemsContainer = ensureMainAdminItemsSentinel();
if (!itemsContainer) return;
if (mainAdminItemsObserver) {
mainAdminItemsObserver.disconnect();
mainAdminItemsObserver = null;
}
if (!mainAdminItemsScrollPrefetchBound) {
itemsContainer.addEventListener('scroll', maybePrefetchMainAdminItems, { passive: true });
mainAdminItemsScrollPrefetchBound = true;
}
if (!mainAdminItemsHasMore) return;
mainAdminItemsObserver = new IntersectionObserver(entries => {
if (!entries.some(entry => entry.isIntersecting)) {
return;
}
if (mainAdminItemsLoadingMore || !mainAdminItemsHasMore) {
return;
}
mainAdminItemsLoadingMore = true;
loadItems(mainAdminItemsNextOffset, true).finally(() => {
mainAdminItemsLoadingMore = false;
});
}, {
root: itemsContainer,
threshold: 0.15,
rootMargin: '0px 900px 0px 0px'
});
mainAdminItemsObserver.observe(mainAdminItemsSentinel);
}
function searchByCode() {
const searchInput = document.getElementById('code-search');
const rawSearchTerm = searchInput.value;
+22
View File
@@ -710,6 +710,28 @@
<div class="upload-container">
<a href="{{ url_for(back_target|default('home_admin')) }}" class="nav-back-button">← Zurück zur Artikelübersicht</a>
{% if show_library_features %}
<div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
<h3 style="margin:0 0 8px 0;">Excel-Import Bibliothek (Mehrere Bücher)</h3>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, ISBN, Code, Anzahl). Für den Bibliotheksimport ist eine gültige ISBN je Zeile erforderlich.</p>
<form method="POST" action="{{ url_for('upload_library_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<input type="file" name="library_excel" accept=".xlsx" required>
<button type="submit" class="btn btn-secondary" name="excel_action" value="validate">Nur validieren</button>
<button type="submit" class="btn btn-primary" name="excel_action" value="import">Bibliothek importieren</button>
</form>
</div>
{% else %}
<div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
<h3 style="margin:0 0 8px 0;">Excel-Import Inventar (Mehrere Artikel)</h3>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, Filter1/2/3, Kosten, Jahr, Code, Anzahl).</p>
<form method="POST" action="{{ url_for('upload_inventory_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<input type="file" name="inventory_excel" accept=".xlsx" required>
<button type="submit" class="btn btn-secondary" name="excel_action" value="validate">Nur validieren</button>
<button type="submit" class="btn btn-primary" name="excel_action" value="import">Inventar importieren</button>
</form>
</div>
{% endif %}
<div class="upload-form">
<h1>{{ page_title|default('Artikel hochladen') }}</h1>
+1 -1
View File
@@ -10,10 +10,10 @@ Provides methods for creating, validating, and retrieving user information.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
'''
from pymongo import MongoClient
import hashlib
from bson.objectid import ObjectId
import settings as cfg
from settings import MongoClient
def normalize_student_card_id(card_id):
+2 -1
View File
@@ -9,4 +9,5 @@ python-dateutil
pytz
requests
reportlab
python-barcode
python-barcode
openpyxl
Binary file not shown.
Binary file not shown.
Binary file not shown.