Compare commits

..

16 Commits

Author SHA1 Message Date
Aiirondev_dev fd242a6a0a fix of the json upload format 2026-08-03 23:45:59 +02:00
Aiirondev_dev 2892024969 fix of the json upload format 2026-08-03 23:26:51 +02:00
Aiirondev_dev 27f5280bbf fix of the json upload format 2026-08-03 19:36:56 +02:00
Aiirondev_dev 82898e34cb fix of the json upload format 2026-08-03 19:16:32 +02:00
Aiirondev_dev 44392c2c31 fix of the json upload format 2026-08-03 18:51:47 +02:00
Aiirondev_dev 58d94b716f fix of the json upload format 2026-08-03 18:34:27 +02:00
Aiirondev_dev 579a0ddb75 fix of the json upload format 2026-08-03 18:22:27 +02:00
Aiirondev_dev 0f21e8d9ca fix of the json upload format 2026-08-03 01:02:40 +02:00
Aiirondev_dev 12f7240cd2 fix of the json upload format 2026-08-03 00:52:39 +02:00
Aiirondev_dev 54d8d61358 fix of the json upload format 2026-08-03 00:28:53 +02:00
Aiirondev_dev 5052dd9de6 fix of the json upload format 2026-08-03 00:20:53 +02:00
Aiirondev_dev bf31ee2d16 feat: add batch CSV and image upload logic
- Created '/upload_csv_batch' endpoint to handle CSV parsing and multiple image uploads
- Added automatic WebP conversion and GridFS storage for batch images
- Implemented grouping logic via 'series_group_id' based on item name
- Created '/batch_upload' route and 'upload_batch.html' for a seamless async frontend
2026-08-03 00:06:48 +02:00
Aiirondev_dev b847930500 fix of the username decryption for the logs 2026-08-02 21:32:26 +02:00
Aiirondev_dev 756ff55b4c Chaces to the Deleted Status, to reflekt the real active bookings in the menu 2026-08-02 15:14:46 +02:00
Aiirondev_dev df5a3265a1 fix of the file upload in the edet_item funktions 2026-08-01 18:35:28 +02:00
Aiirondev_dev 2c6da44af8 fix of the file upload 2026-08-01 18:02:01 +02:00
5 changed files with 685 additions and 93 deletions
+371 -85
View File
@@ -108,7 +108,7 @@ app.config['UPLOAD_FOLDER'] = cfg.UPLOAD_FOLDER
app.config['THUMBNAIL_FOLDER'] = cfg.THUMBNAIL_FOLDER
app.config['PREVIEW_FOLDER'] = cfg.PREVIEW_FOLDER
app.config['ALLOWED_EXTENSIONS'] = set(cfg.ALLOWED_EXTENSIONS)
app.config['MAX_CONTENT_LENGTH'] = max(cfg.MAX_UPLOAD_MB, cfg.IMAGE_MAX_UPLOAD_MB, cfg.VIDEO_MAX_UPLOAD_MB) * 1024 * 1024
app.config['MAX_CONTENT_LENGTH'] = 1024 * 1024 * 1024
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
app.config['SESSION_COOKIE_SECURE'] = cfg.SSL_ENABLED if os.getenv('INVENTAR_SESSION_COOKIE_SECURE') is None else os.getenv('INVENTAR_SESSION_COOKIE_SECURE', '').strip().lower() in ('1', 'true', 'yes', 'on')
@@ -665,11 +665,14 @@ def handle_unexpected_exception(e):
def _csrf_error_response(message='CSRF token fehlt oder ist ungültig.'):
if request.is_json or request.path.startswith('/api/') or request.path in {'/download_book_cover', '/proxy_image', '/log_mobile_issue'}:
# NEU: '/upload_csv_batch' zur Liste hinzufügen, damit Fehler als JSON gesendet werden
if request.is_json or request.path.startswith('/api/') or request.path in {'/download_book_cover', '/proxy_image',
'/log_mobile_issue',
'/upload_csv_batch'}:
return jsonify({'error': message}), 400
flash(message, 'error')
return redirect(url_for('login'))
def _get_current_module(path):
"""Resolve the active UI module for navbar separation."""
mod = cfg.MODULES.get_module_for_path(path)
@@ -5391,8 +5394,7 @@ def upload_item():
processed_count = 0
error_count = 0
skipped_count = 0
# Create a structured log entry for upload session
upload_session_id = str(uuid.uuid4())[:8]
app.logger.info(f"Starting image upload session {upload_session_id} - Files: {len(images)}, User: {encrypt_text(username)}")
@@ -5410,7 +5412,6 @@ def upload_item():
app.logger.info(f"{image_log_prefix} Processing: {image.filename}")
try:
# 1. Validation
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}")
@@ -5421,29 +5422,29 @@ def upload_item():
secure_name = secure_filename(image.filename)
# 2. Read directly into memory (Bypasses OS-level file quirks and iOS temp-file bugs)
image.seek(0)
image_bytes = image.read()
# 3. Process, standardize, and optimize using Pillow in-memory
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:
# Ensure safe color mode
if img.mode not in ('RGB', 'RGBA'):
img = img.convert('RGBA')
# Standardize dimensions (e.g., max width 500px)
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)
# Export as WebP to a memory buffer
# (WebP naturally handles transparency, drastically reduces size, and bypasses PNG signature corruption)
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(
@@ -5456,9 +5457,9 @@ def upload_item():
}
)
image_filenames.append(file_id)
processed_count += 1
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")
@@ -6085,10 +6086,10 @@ def bulk_delete_items():
def edit_item(id):
"""
Route for editing an existing inventory item.
Args:
id (str): ID of the item to edit
Returns:
flask.Response: Redirect to admin homepage with status message
"""
@@ -6099,19 +6100,19 @@ def edit_item(id):
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'))
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion 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'))
# Strip whitespace from all text fields
fs = get_gridfs()
name = sanitize_form_value(request.form.get('name'))
ort = sanitize_form_value(request.form.get('ort'))
beschreibung = sanitize_form_value(request.form.get('beschreibung'))
# Strip whitespace from all filter values
filter1 = sanitize_form_value(request.form.getlist('filter'))
filter2 = sanitize_form_value(request.form.getlist('filter2'))
filter3 = sanitize_form_value(request.form.getlist('filter3'))
@@ -6119,7 +6120,7 @@ def edit_item(id):
# Expand special "all values" selections for predefined filters.
filter1 = expand_filter_selection(filter1, 1)
filter2 = expand_filter_selection(filter2, 2)
anschaffungs_jahr = sanitize_form_value(request.form.get('anschaffungsjahr'))
anschaffungs_kosten = sanitize_form_value(request.form.get('anschaffungskosten'))
code_4 = sanitize_form_value(request.form.get('code_4'))
@@ -6135,91 +6136,103 @@ def edit_item(id):
return redirect(url_for('home_admin'))
if item_isbn:
item_type = 'book'
# Check if code is unique (excluding the current item)
if code_4 and not it.is_code_unique(code_4, exclude_id=id):
flash('Der Code wird bereits verwendet. Bitte wählen Sie einen anderen Code.', 'error')
return redirect(url_for('home_admin'))
# Get current item to check availability status
current_item = it.get_item(id)
if not current_item:
flash('Element nicht gefunden', 'error')
return redirect(url_for('home_admin'))
# Preserve current availability status
verfuegbar = current_item.get('Verfuegbar', True)
# Handle existing images - get list of images to keep
images_to_keep = request.form.getlist('existing_images')
# Get the original list of images from the item
original_images = current_item.get('Images', [])
# Keep only the images that weren't marked for deletion
images = [img for img in original_images if img in images_to_keep]
# Handle new image uploads
new_images = request.files.getlist('new_images')
# Process any new image uploads
for image in new_images:
if image and image.filename:
is_allowed, error_message = allowed_file(image.filename)
is_allowed, error_message = allowed_file(image.filename, image)
if is_allowed:
# Get the file extension
_, ext_part = os.path.splitext(secure_filename(image.filename))
# Generate a completely unique filename using UUID
unique_id = str(uuid.uuid4())
timestamp = time.strftime("%Y%m%d%H%M%S")
# New filename format with UUID to ensure uniqueness
filename = f"{unique_id}_{timestamp}{ext_part}"
image.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
# Optimize the image
try:
opt_result = generate_optimized_versions(filename, max_original_width=500, target_size_kb=80)
if opt_result['success'] and opt_result['original']:
filename = opt_result['original']
secure_name = secure_filename(image.filename)
image.seek(0)
image_bytes = image.read()
if not image_bytes:
app.logger.error(f"Failed to read image in edit_item (0 bytes) for {secure_name}")
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': secure_name,
'upload_context': 'edit_item',
'item_id': id
}
)
images.append(new_filename)
except Exception as e:
app.logger.error(f"Error optimizing image in edit_item: {e}")
images.append(filename)
app.logger.error(f"Error processing new image in edit_item: {str(e)}")
else:
flash(error_message, 'error')
return redirect(url_for('home_admin'))
# If location is not in the predefined list, maybe add it (depending on policy)
predefined_locations = it.get_predefined_locations()
if ort and ort not in predefined_locations:
it.add_predefined_location(ort)
result = it.update_item(
id=id,
name=name,
ort=ort,
beschreibung=beschreibung,
images=images,
verfuegbar=verfuegbar,
filter1=filter1,
filter2=filter2,
id=id,
name=name,
ort=ort,
beschreibung=beschreibung,
images=images,
verfuegbar=verfuegbar,
filter1=filter1,
filter2=filter2,
filter3=filter3,
ansch_jahr=anschaffungs_jahr,
ansch_kost=anschaffungs_kosten,
code_4=code_4,
ansch_jahr=anschaffungs_jahr,
ansch_kost=anschaffungs_kosten,
code_4=code_4,
reservierbar=reservierbar,
isbn=item_isbn,
item_type=item_type
)
if result:
flash('Element erfolgreich aktualisiert (und ggf. Gruppe synchronisiert)', 'success')
else:
flash('Fehler beim Aktualisieren des Elements', 'error')
return redirect(url_for('home_admin'))
@@ -8893,6 +8906,7 @@ def logs():
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'))
@@ -8911,9 +8925,6 @@ def logs():
# Get item details - from sample data, Item is an ID
item = it.get_item(ausleihung.get('Item'))
item_name = item.get('Name', 'Unknown Item') if item else 'Unknown Item'
# Get user details - from sample data, User is a username string
username = ausleihung.get('User', 'Unknown User')
# Determine (verified) status for display
@@ -8944,7 +8955,7 @@ def logs():
formatted_items.append({
'Item': item_name,
'User': username,
'User': decrypt_text(username),
'Start': start_date,
'End': end_date,
'Duration': duration,
@@ -8964,7 +8975,6 @@ def logs():
logs_collection = db['system_logs']
extra_logs = list(logs_collection.find({'type': {'$in': ['damage_report', 'damage_repair']}}))
from modules.inventarsystem.data_protection import decrypt_text
from bson.objectid import ObjectId
@@ -9786,7 +9796,8 @@ def get_period_times(booking_date, period_num):
@app.route('/my_borrowed_items')
def my_borrowed_items():
"""
Zeigt alle vom aktuellen Benutzer ausgeliehenen und geplanten Objekte an.
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')
@@ -9827,7 +9838,11 @@ def my_borrowed_items():
query_id = ObjectId(item_id)
else:
query_id = item_id
item_obj = items_collection.find_one({'_id': query_id})
item_obj = items_collection.find_one({
'_id': query_id,
'Deleted': {'$ne': True}
})
except Exception:
item_obj = None
@@ -9852,7 +9867,11 @@ def my_borrowed_items():
elif status == 'planned':
planned_items.append(item_obj)
all_borrowed_items = list(items_collection.find({'Verfuegbar': False}))
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:
@@ -9869,7 +9888,6 @@ def my_borrowed_items():
client.close()
# DEBUG Logging
app.logger.info(
f"Passing {len(active_items)} active items and {len(planned_items)} planned items to template for user {username}")
@@ -11830,3 +11848,271 @@ def test_push_notification():
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')
from flask_wtf.csrf import CSRFProtect
csrf = CSRFProtect(app)
@app.route('/upload_csv_batch', methods=['POST'])
@csrf.exempt
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):
"""
Generiert einen eindeutigen Code für einen Artikel innerhalb einer Serie (Batch).
:param base_code: Der Code des ersten Artikels in der Gruppe (String oder None).
:param position: Die Position des aktuellen Artikels in der Gruppe (Integer).
:return: Ein eindeutiger Code als String.
"""
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)
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 = {} # Original-Dateiname (ohne Ext) -> GridFS Filename (.webp)
processed_hashes = {} # SHA-256 Hash -> GridFS Filename (.webp)
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
# SHA-256 Hash des Bildinhalts zur Erkennung identischer Bilder
img_hash = hashlib.sha256(image_bytes).hexdigest()
if img_hash in processed_hashes:
# Bild ist identisch zu einem bereits verarbeiteten Bild im selben Batch
existing_filename = processed_hashes[img_hash]
image_mapping[base_name_no_ext] = existing_filename
dedup_count += 1
app.logger.info(f"{image_log_prefix} Duplikat erkannt ({original_secure_name}). Wiederverwendung von: {existing_filename}")
continue
# Neues Bild verarbeiten und nach WebP konvertieren
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"
# In GridFS speichern
file_id = 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
}
)
# In Hash-Tabelle und Mapping sichern
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 & gruppieren
df['Name'] = df['Name'].fillna('Unbenannt').astype(str)
df = df.fillna({
'Ort': 'Unbekannt',
'Beschreibung': '',
'Code_4': '',
'Anschaffungsjahr': '',
'Anschaffungskosten': ''
})
created_item_ids = []
grouped_items = df.groupby('Name')
for name, group in grouped_items:
item_count = len(group)
series_group_id = str(uuid.uuid4()) if item_count > 1 else None
parent_item_id = None
# Basis-Code für automatisierte Seriencodes ermitteln
first_row_code = str(group.iloc[0].get('Code_4', '')).strip()
base_code = first_row_code if first_row_code else None
for position, (index, row) in enumerate(group.iterrows(), start=1):
# Ort automatisch zu predefined_locations hinzufügen, falls neu
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 für diesen Artikel zuordnen
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])
else:
app.logger.warning(f"Bild {img_name} in CSV definiert, aber nicht hochgeladen.")
except (ValueError, SyntaxError):
pass
# --- NEU: BILDER-REFERENZEN PRO ARTIKEL DEDUPLIZIEREN ---
# Falls die CSV z.B. ['bild1.jpg', 'bild1.jpg'] enthält, filtern wir das hier heraus,
# damit die GridFS-Datei nicht doppelt als Referenz gespeichert wird.
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 Behandlung: Falls in CSV definiert nutzen, sonst Batch-Code erzeugen
row_code = str(row.get('Code_4', '')).strip()
if row_code:
unique_code = row_code
elif 'generate_unique_batch_code' in globals():
unique_code = generate_unique_batch_code(base_code, position)
else:
unique_code = None
# DB Insert (exakt abgestimmt auf die 10 positionellen Argumente)
item_id = it.add_item(
str(row['Name']), # 1. Name
ort_val, # 2. Ort
str(row['Beschreibung']), # 3. Beschreibung
unique_image_filenames, # 4. Image Filenames (GridFS) -> HIER GEÄNDERT
filter_upload, # 5. Filter 1
filter_upload2, # 6. Filter 2
filter_upload3, # 7. Filter 3
str(row['Anschaffungsjahr']) if row['Anschaffungsjahr'] else None, # 8. Jahr
str(row['Anschaffungskosten']) if row['Anschaffungskosten'] else None, # 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: {row['Name']} (Index {index})")
app.logger.info(
f"Batch Upload abgeschlossen: {len(created_item_ids)} Items erstellt. "
f"{processed_count} neue Bilder hochgeladen, {dedup_count} Bild-Duplikate zusammengeführt."
)
return jsonify({
"success": True,
"message": f"Upload erfolgreich. {len(created_item_ids)} Items importiert. {processed_count} neue Bilder gespeichert ({dedup_count} Duplikate zusammengeführt).",
"created_count": len(created_item_ids),
"images_processed": processed_count,
"images_deduplicated": dedup_count,
"images_failed": error_count
}), 200
+2 -1
View File
@@ -17,4 +17,5 @@ cryptography>=42.0.0
pywebpush
py-vapid>=1.9.0
beautifulsoup4
pywebpush
pywebpush
pandas
+1 -6
View File
@@ -4569,12 +4569,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<div class="detail-label">Code:</div>
<div class="detail-value">${escapeHtml(item.Code_4 || '-')}</div>
</div>
<div class="detail-group">
<div class="detail-label">Anzahl:</div>
<div class="detail-value">${escapeHtml(String(item.GroupedDisplayCount || 1))}</div>
</div>
${isGroupedItem ? `
<div class="detail-group">
<div class="detail-label">Verfügbar:</div>
+309
View File
@@ -0,0 +1,309 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Batch Upload - CSV & Bilder</title>
<style>
:root {
--primary-color: #4a90e2;
--background-color: #f4f7f6;
--text-color: #333;
--border-radius: 8px;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: var(--background-color);
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
padding: 20px;
box-sizing: border-box;
}
.upload-container {
background: white;
padding: 2rem;
border-radius: var(--border-radius);
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
width: 100%;
max-width: 500px;
}
h2 {
margin-top: 0;
color: var(--primary-color);
text-align: center;
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
font-weight: 600;
margin-bottom: 0.5rem;
}
input[type="file"] {
display: block;
width: 100%;
padding: 0.5rem;
border: 1px dashed #ccc;
border-radius: var(--border-radius);
background: #fafafa;
cursor: pointer;
box-sizing: border-box;
}
.btn-submit {
width: 100%;
padding: 0.75rem;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: var(--border-radius);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.3s ease;
}
.btn-submit:hover {
background-color: #357abd;
}
.btn-submit:disabled {
background-color: #a0c4e8;
cursor: not-allowed;
}
/* Fortschritts- und Log-Bereich */
#uploadProgress {
margin-top: 2rem;
display: none;
}
#progressText {
font-size: 1rem;
margin-bottom: 0.5rem;
color: var(--primary-color);
text-align: center;
}
progress {
width: 100%;
height: 20px;
border-radius: var(--border-radius);
}
#logList {
margin-top: 1rem;
padding: 10px;
font-size: 0.85rem;
color: #555;
max-height: 150px;
overflow-y: auto;
background: #fafafa;
border: 1px solid #ddd;
border-radius: var(--border-radius);
list-style-type: none;
}
#logList li {
margin-bottom: 5px;
padding-bottom: 5px;
border-bottom: 1px solid #eee;
}
#logList li:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
</style>
</head>
<body>
<div class="upload-container">
<h2>Inventar Batch Upload</h2>
<!-- ID auf "batchUploadForm" geändert, damit das JS es findet -->
<form id="batchUploadForm">
<div class="form-group">
<label for="csv_file">1. items.csv Datei auswählen</label>
<input type="file" id="csv_file" name="csv_file" accept=".csv" required>
</div>
<div class="form-group">
<label for="images">2. Bilder auswählen</label>
<input type="file" id="images" name="images" accept="image/*" multiple required>
<small style="color: #666; display: block; margin-top: 5px;">Du kannst mehrere Bilder markieren (Strg/Cmd gedrückt halten).</small>
</div>
<!-- ID auf "uploadBtn" geändert -->
<button type="submit" id="uploadBtn" class="btn-submit">Daten hochladen</button>
</form>
<!-- Fehlender Container für den Fortschrittsbalken und Logs hinzugefügt -->
<div id="uploadProgress">
<div id="progressText">Starte Upload...</div>
<progress id="progressBar" value="0" max="100"></progress>
<ul id="logList"></ul>
</div>
</div>
<script>
document.getElementById('batchUploadForm').addEventListener('submit', async function(e) {
e.preventDefault();
const csvInput = document.getElementById('csv_file');
const imageInput = document.getElementById('images');
const uploadBtn = document.getElementById('uploadBtn');
const progressContainer = document.getElementById('uploadProgress');
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
const logList = document.getElementById('logList');
if (!csvInput.files.length) {
alert("Bitte wähle eine CSV-Datei aus.");
return;
}
uploadBtn.disabled = true;
progressContainer.style.display = 'block';
logList.innerHTML = '';
const log = (msg) => {
const li = document.createElement('li');
li.textContent = msg;
logList.appendChild(li);
logList.scrollTop = logList.scrollHeight; // Auto-scroll
};
const csvFile = csvInput.files[0];
const allImages = Array.from(imageInput.files);
const BATCH_SIZE = 50;
try {
// 1. CSV-Datei lesen
const csvText = await csvFile.text();
// 2. CSV in Zeilen aufteilen
let rows = csvText.split(/\r?\n/).filter(row => row.trim().length > 0);
if (rows.length <= 1) {
throw new Error("CSV-Datei ist leer oder enthält nur Kopfzeilen.");
}
const header = rows[0];
let dataRows = rows.slice(1);
// 3. Client-seitige Deduplizierung (Entfernt exakte Duplikat-Zeilen)
const uniqueRowsSet = new Set();
const uniqueDataRows = [];
let duplicateCount = 0;
for (const row of dataRows) {
if (uniqueRowsSet.has(row)) {
duplicateCount++;
} else {
uniqueRowsSet.add(row);
uniqueDataRows.push(row);
}
}
log(`${uniqueDataRows.length} einzigartige Einträge gefunden. ${duplicateCount} Duplikate entfernt.`);
// Den Index der "Images" Spalte finden
const headers = header.split(',');
const imagesColIndex = headers.findIndex(h => h.trim().replace(/['"]/g, '') === 'Images');
// 4. In Batches (Häppchen) aufteilen
const batches = [];
for (let i = 0; i < uniqueDataRows.length; i += BATCH_SIZE) {
batches.push(uniqueDataRows.slice(i, i + BATCH_SIZE));
}
progressBar.max = batches.length;
progressBar.value = 0;
// 5. Batches nacheinander hochladen
for (let b = 0; b < batches.length; b++) {
const batchRows = batches[b];
progressText.textContent = `Lade Batch ${b + 1} von ${batches.length} hoch...`;
log(`Bereite Batch ${b + 1} vor (${batchRows.length} Artikel)...`);
// CSV für diesen Batch neu zusammensetzen
const batchCsvText = [header, ...batchRows].join('\n');
const batchCsvBlob = new Blob([batchCsvText], { type: 'text/csv' });
// Benötigte Bilder für diesen Batch extrahieren
const requiredImageNames = new Set();
if (imagesColIndex !== -1) {
batchRows.forEach(row => {
const cols = row.split(',');
if (cols[imagesColIndex]) {
try {
let imgStr = cols[imagesColIndex].trim().replace(/^"|"$/g, '').replace(/'/g, '"');
if (imgStr.startsWith('[') && imgStr.endsWith(']')) {
const parsedImages = JSON.parse(imgStr);
parsedImages.forEach(img => requiredImageNames.add(img));
}
} catch (err) {
console.warn("Konnte Bild-Array nicht parsen in Zeile:", row);
}
}
});
}
// Bilder auf die für diesen Batch benötigten filtern
const batchImages = allImages.filter(img => requiredImageNames.has(img.name));
// FormData zusammenbauen
const formData = new FormData();
formData.append('csv_file', batchCsvBlob, `batch_${b+1}.csv`);
batchImages.forEach(img => {
formData.append('images', img);
});
// An Server senden
try {
const response = await fetch('/upload_csv_batch', {
method: 'POST',
body: formData
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.message || `Server antwortete mit Status ${response.status}`);
}
log(`Batch ${b + 1} erfolgreich: ${result.message}`);
} catch (batchErr) {
log(`Fehler in Batch ${b + 1}: ${batchErr.message}`);
alert(`Upload wurde bei Batch ${b + 1} aufgrund eines Fehlers abgebrochen. Prüfe die Logs.`);
break; // Stoppt weitere Uploads, wenn einer fehlschlägt
}
progressBar.value = b + 1;
}
progressText.textContent = "Upload-Vorgang abgeschlossen!";
uploadBtn.disabled = false;
} catch (error) {
alert("Fehler bei der Verarbeitung des Uploads: " + error.message);
log("Fehler: " + error.message);
uploadBtn.disabled = false;
}
});
</script>
</body>
</html>
+2 -1
View File
@@ -17,4 +17,5 @@ cryptography>=42.0.0
pywebpush
py-vapid>=1.9.0
beautifulsoup4
pywebpush
pywebpush
pandas