Compare commits

...

6 Commits

2 changed files with 104 additions and 50 deletions
+86 -46
View File
@@ -417,6 +417,8 @@ def _is_csrf_exempt_request():
@app.before_request @app.before_request
def _enforce_csrf_protection(): def _enforce_csrf_protection():
if request.endpoint == 'upload_csv_batch':
return None
if _is_csrf_exempt_request(): if _is_csrf_exempt_request():
_get_csrf_token() _get_csrf_token()
return None return None
@@ -665,11 +667,14 @@ def handle_unexpected_exception(e):
def _csrf_error_response(message='CSRF token fehlt oder ist ungültig.'): 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 return jsonify({'error': message}), 400
flash(message, 'error') flash(message, 'error')
return redirect(url_for('login')) return redirect(url_for('login'))
def _get_current_module(path): def _get_current_module(path):
"""Resolve the active UI module for navbar separation.""" """Resolve the active UI module for navbar separation."""
mod = cfg.MODULES.get_module_for_path(path) mod = cfg.MODULES.get_module_for_path(path)
@@ -11860,22 +11865,23 @@ def batch_upload_page():
return render_template('upload_batch.html') return render_template('upload_batch.html')
from flask_wtf.csrf import CSRFProtect
csrf = CSRFProtect(app)
@app.route('/upload_csv_batch', methods=['POST']) @app.route('/upload_csv_batch', methods=['POST'])
@csrf.exempt
def upload_csv_batch(): def upload_csv_batch():
""" """
Route for batch adding new items to the inventory via CSV. Route for batch adding new items to the inventory via CSV.
Handles CSV parsing, bulk image upload (conversion to WebP), GridFS storage, Handles CSV parsing, bulk image upload with deduplication (SHA-256 hash matching),
and groups identical items based on their Name. GridFS storage, code generation, location syncing, and grouped item creation.
""" """
import pandas as pd import pandas as pd
import ast import ast
if 'username' not in session: import hashlib
return jsonify({'success': False, 'message': 'Nicht angemeldet'}), 401
username = session['username'] username = session.get('username', 'System')
# permissions = _get_current_user_permissions() ... (anpassen wie in Original)
# if not _action_access_allowed(permissions, 'can_insert'):
# return jsonify({'success': False, 'message': 'Einfüge-Rechte erforderlich'}), 403
fs = get_gridfs() fs = get_gridfs()
upload_session_id = str(uuid.uuid4())[:8] upload_session_id = str(uuid.uuid4())[:8]
@@ -11898,10 +11904,11 @@ def upload_csv_batch():
if 'Name' not in df.columns: if 'Name' not in df.columns:
return jsonify({"success": False, "message": "Die CSV muss zwingend eine 'Name' Spalte enthalten."}), 400 return jsonify({"success": False, "message": "Die CSV muss zwingend eine 'Name' Spalte enthalten."}), 400
# 3. Bilder verarbeiten, nach WebP konvertieren und in GridFS speichern # 3. Bilder verarbeiten & Duplikate im selben Durchlauf filtern (Hash-Matching)
# Mapping: Original-Dateiname (ohne Pfad/Erweiterung) -> GridFS Filename (.webp) image_mapping = {} # Original-Dateiname (ohne Ext) -> GridFS Filename (.webp)
image_mapping = {} processed_hashes = {} # SHA-256 Hash -> GridFS Filename (.webp)
processed_count = 0 processed_count = 0
dedup_count = 0
error_count = 0 error_count = 0
for index, image in enumerate(uploaded_images): for index, image in enumerate(uploaded_images):
@@ -11913,14 +11920,24 @@ def upload_csv_batch():
image_log_prefix = f"[Upload {upload_session_id}][Image {index + 1}/{len(uploaded_images)}]" image_log_prefix = f"[Upload {upload_session_id}][Image {index + 1}/{len(uploaded_images)}]"
try: try:
# Annahme: is_allowed, error_message = allowed_file(...)
image.seek(0) image.seek(0)
image_bytes = image.read() image_bytes = image.read()
if not image_bytes: if not image_bytes:
error_count += 1 error_count += 1
continue 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() optimized_io = io.BytesIO()
with Image.open(io.BytesIO(image_bytes)) as img: with Image.open(io.BytesIO(image_bytes)) as img:
if img.mode not in ('RGB', 'RGBA'): if img.mode not in ('RGB', 'RGBA'):
@@ -11937,7 +11954,7 @@ def upload_csv_batch():
optimized_io.seek(0) optimized_io.seek(0)
new_filename = f"{uuid.uuid4().hex}_{int(time.time())}.webp" new_filename = f"{uuid.uuid4().hex}_{int(time.time())}.webp"
# Speichern in GridFS analog zu upload_item # In GridFS speichern
file_id = fs.put( file_id = fs.put(
optimized_io, optimized_io,
filename=new_filename, filename=new_filename,
@@ -11949,7 +11966,8 @@ def upload_csv_batch():
} }
) )
# Im Mapping speichern (damit wir sie später der CSV zuordnen können) # In Hash-Tabelle und Mapping sichern
processed_hashes[img_hash] = new_filename
image_mapping[base_name_no_ext] = new_filename image_mapping[base_name_no_ext] = new_filename
processed_count += 1 processed_count += 1
@@ -11957,11 +11975,14 @@ def upload_csv_batch():
app.logger.error(f"{image_log_prefix} Processing failed: {str(e)}") app.logger.error(f"{image_log_prefix} Processing failed: {str(e)}")
error_count += 1 error_count += 1
# 4. Items gruppieren (Analog zu series_group_id aus upload_item) # 4. Predefined Locations laden
# Gruppierung über den Namen: Alle Zeilen mit demselben Namen gehören zur selben Serie try:
df['Name'] = df['Name'].fillna('Unbenannt').astype(str) predefined_locations = it.get_predefined_locations()
except Exception:
predefined_locations = []
# Optional: Fülle NaN Werte in der CSV mit sinnvollen Defaults für die Datenbank # 5. Dataframe bereinigen & gruppieren
df['Name'] = df['Name'].fillna('Unbenannt').astype(str)
df = df.fillna({ df = df.fillna({
'Ort': 'Unbekannt', 'Ort': 'Unbekannt',
'Beschreibung': '', 'Beschreibung': '',
@@ -11971,7 +11992,6 @@ def upload_csv_batch():
}) })
created_item_ids = [] created_item_ids = []
grouped_items = df.groupby('Name') grouped_items = df.groupby('Name')
for name, group in grouped_items: for name, group in grouped_items:
@@ -11979,18 +11999,29 @@ def upload_csv_batch():
series_group_id = str(uuid.uuid4()) if item_count > 1 else None series_group_id = str(uuid.uuid4()) if item_count > 1 else None
parent_item_id = 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): for position, (index, row) in enumerate(group.iterrows(), start=1):
# Bilder aus der CSV-Zeile extrahieren und über das image_mapping mappen # 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 = [] item_image_filenames = []
if 'Images' in row and pd.notna(row['Images']): if 'Images' in row and pd.notna(row['Images']):
try: try:
# Aus "['Bild1.JPG', 'Bild2.JPG']" wird eine Liste
img_list = ast.literal_eval(str(row['Images'])) img_list = ast.literal_eval(str(row['Images']))
if isinstance(img_list, list): if isinstance(img_list, list):
for img_name in img_list: for img_name in img_list:
base_img_name = os.path.splitext(img_name)[0] base_img_name = os.path.splitext(img_name)[0]
# Falls das Bild hochgeladen wurde, die WebP GridFS ID/Name nehmen
if base_img_name in image_mapping: if base_img_name in image_mapping:
item_image_filenames.append(image_mapping[base_img_name]) item_image_filenames.append(image_mapping[base_img_name])
else: else:
@@ -11998,12 +12029,11 @@ def upload_csv_batch():
except (ValueError, SyntaxError): except (ValueError, SyntaxError):
pass pass
# Filter extrahieren (falls vorhanden, erwarte string list wie "['HSU', '', '', '']")
def parse_filter_col(col_data): def parse_filter_col(col_data):
try: try:
res = ast.literal_eval(str(col_data)) res = ast.literal_eval(str(col_data))
return res if isinstance(res, list) else [] return res if isinstance(res, list) else []
except: except Exception:
return [] return []
filter_upload = parse_filter_col(row.get('Filter', '[]')) filter_upload = parse_filter_col(row.get('Filter', '[]'))
@@ -12012,46 +12042,56 @@ def upload_csv_batch():
reservierbar = bool(row.get('Reservierbar', False)) reservierbar = bool(row.get('Reservierbar', False))
# DB Insert Funktion aufrufen (orientiert an deiner upload_item) # 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( item_id = it.add_item(
name=row['Name'], str(row['Name']), # 1. Name
ort=row['Ort'], ort_val, # 2. Ort
beschreibung=row['Beschreibung'], str(row['Beschreibung']), # 3. Beschreibung
image_filenames=item_image_filenames, item_image_filenames, # 4. Image Filenames (GridFS)
filter_upload=filter_upload, filter_upload, # 5. Filter 1
filter_upload2=filter_upload2, filter_upload2, # 6. Filter 2
filter_upload3=filter_upload3, filter_upload3, # 7. Filter 3
anschaffungs_jahr=str(row['Anschaffungsjahr']) if row['Anschaffungsjahr'] else None, str(row['Anschaffungsjahr']) if row['Anschaffungsjahr'] else None, # 8. Jahr
anschaffungs_kosten=str(row['Anschaffungskosten']) if row['Anschaffungskosten'] else None, str(row['Anschaffungskosten']) if row['Anschaffungskosten'] else None, # 9. Kosten
code_4=str(row['Code_4']) if row['Code_4'] else None, unique_code, # 10. Unique Code / Code_4
reservierbar=reservierbar, reservierbar=reservierbar,
series_group_id=series_group_id, series_group_id=series_group_id,
series_count=item_count, series_count=item_count,
series_position=position, series_position=position,
is_grouped_sub_item=(position > 1), is_grouped_sub_item=(position > 1),
parent_item_id=parent_item_id, parent_item_id=parent_item_id,
# Default Werte, falls keine Bibliotheks-CSV isbn=str(row.get('ISBN', '')),
isbn='', item_type=str(row.get('Item_Type', 'other')),
item_type='other', library_category=str(row.get('Library_Category', '')),
library_category='', is_library=bool(row.get('Is_Library', False))
is_library=False
) )
if item_id: if item_id:
created_item_ids.append(item_id) created_item_ids.append(item_id)
# Das erste Item in einer Serie wird der Parent für die restlichen
if position == 1: if position == 1:
parent_item_id = str(item_id) parent_item_id = str(item_id)
else: else:
app.logger.error(f"Fehler beim Erstellen von Item: {row['Name']} (Index {index})") app.logger.error(f"Fehler beim Erstellen von Item: {row['Name']} (Index {index})")
app.logger.info( app.logger.info(
f"Batch Upload abgeschlossen: {len(created_item_ids)} Items erstellt. {processed_count} Bilder verarbeitet.") f"Batch Upload abgeschlossen: {len(created_item_ids)} Items erstellt. "
f"{processed_count} neue Bilder hochgeladen, {dedup_count} Bild-Duplikate zusammengeführt."
)
return jsonify({ return jsonify({
"success": True, "success": True,
"message": f"Upload erfolgreich. {len(created_item_ids)} Items importiert und {processed_count} Bilder konvertiert.", "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), "created_count": len(created_item_ids),
"images_processed": processed_count, "images_processed": processed_count,
"images_deduplicated": dedup_count,
"images_failed": error_count "images_failed": error_count
}), 200 }), 200
+18 -4
View File
@@ -165,11 +165,25 @@
const formData = new FormData(form); const formData = new FormData(form);
const fetchOptions = {
method: 'POST',
body: formData,
credentials: 'include',
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
};
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
if (csrfToken) {
fetchOptions.headers = {
'X-CSRFToken': csrfToken
};
}
try { try {
const response = await fetch('/upload_csv_batch', { const response = await fetch('/upload_csv_batch', fetchOptions);
method: 'POST',
body: formData
});
// Antwort einmalig als Text auslesen, um sowohl JSON als auch HTML-Fehler abzufangen // Antwort einmalig als Text auslesen, um sowohl JSON als auch HTML-Fehler abzufangen
const responseText = await response.text(); const responseText = await response.text();