Compare commits

...

9 Commits

2 changed files with 290 additions and 131 deletions
+110 -47
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')
@@ -417,6 +417,8 @@ def _is_csrf_exempt_request():
@app.before_request
def _enforce_csrf_protection():
if request.endpoint == 'upload_csv_batch':
return None
if _is_csrf_exempt_request():
_get_csrf_token()
return None
@@ -665,11 +667,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)
@@ -11860,22 +11865,37 @@ def batch_upload_page():
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 (conversion to WebP), GridFS storage,
and groups identical items based on their Name.
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
if 'username' not in session:
return jsonify({'success': False, 'message': 'Nicht angemeldet'}), 401
import hashlib
username = session['username']
# 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
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]
@@ -11898,10 +11918,11 @@ def upload_csv_batch():
if 'Name' not in df.columns:
return jsonify({"success": False, "message": "Die CSV muss zwingend eine 'Name' Spalte enthalten."}), 400
# 3. Bilder verarbeiten, nach WebP konvertieren und in GridFS speichern
# Mapping: Original-Dateiname (ohne Pfad/Erweiterung) -> GridFS Filename (.webp)
image_mapping = {}
# 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):
@@ -11913,14 +11934,24 @@ def upload_csv_batch():
image_log_prefix = f"[Upload {upload_session_id}][Image {index + 1}/{len(uploaded_images)}]"
try:
# Annahme: is_allowed, error_message = allowed_file(...)
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'):
@@ -11937,7 +11968,7 @@ def upload_csv_batch():
optimized_io.seek(0)
new_filename = f"{uuid.uuid4().hex}_{int(time.time())}.webp"
# Speichern in GridFS analog zu upload_item
# In GridFS speichern
file_id = fs.put(
optimized_io,
filename=new_filename,
@@ -11949,7 +11980,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
processed_count += 1
@@ -11957,11 +11989,14 @@ def upload_csv_batch():
app.logger.error(f"{image_log_prefix} Processing failed: {str(e)}")
error_count += 1
# 4. Items gruppieren (Analog zu series_group_id aus upload_item)
# Gruppierung über den Namen: Alle Zeilen mit demselben Namen gehören zur selben Serie
df['Name'] = df['Name'].fillna('Unbenannt').astype(str)
# 4. Predefined Locations laden
try:
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({
'Ort': 'Unbekannt',
'Beschreibung': '',
@@ -11971,7 +12006,6 @@ def upload_csv_batch():
})
created_item_ids = []
grouped_items = df.groupby('Name')
for name, group in grouped_items:
@@ -11979,18 +12013,29 @@ def upload_csv_batch():
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):
# 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 = []
if 'Images' in row and pd.notna(row['Images']):
try:
# Aus "['Bild1.JPG', 'Bild2.JPG']" wird eine Liste
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]
# Falls das Bild hochgeladen wurde, die WebP GridFS ID/Name nehmen
if base_img_name in image_mapping:
item_image_filenames.append(image_mapping[base_img_name])
else:
@@ -11998,12 +12043,20 @@ def upload_csv_batch():
except (ValueError, SyntaxError):
pass
# Filter extrahieren (falls vorhanden, erwarte string list wie "['HSU', '', '', '']")
# --- 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:
except Exception:
return []
filter_upload = parse_filter_col(row.get('Filter', '[]'))
@@ -12012,46 +12065,56 @@ def upload_csv_batch():
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(
name=row['Name'],
ort=row['Ort'],
beschreibung=row['Beschreibung'],
image_filenames=item_image_filenames,
filter_upload=filter_upload,
filter_upload2=filter_upload2,
filter_upload3=filter_upload3,
anschaffungs_jahr=str(row['Anschaffungsjahr']) if row['Anschaffungsjahr'] else None,
anschaffungs_kosten=str(row['Anschaffungskosten']) if row['Anschaffungskosten'] else None,
code_4=str(row['Code_4']) if row['Code_4'] else None,
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,
# Default Werte, falls keine Bibliotheks-CSV
isbn='',
item_type='other',
library_category='',
is_library=False
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)
# Das erste Item in einer Serie wird der Parent für die restlichen
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. {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({
"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),
"images_processed": processed_count,
"images_deduplicated": dedup_count,
"images_failed": error_count
}), 200
+180 -84
View File
@@ -21,6 +21,8 @@
align-items: center;
min-height: 100vh;
margin: 0;
padding: 20px;
box-sizing: border-box;
}
.upload-container {
@@ -56,6 +58,7 @@
border-radius: var(--border-radius);
background: #fafafa;
cursor: pointer;
box-sizing: border-box;
}
.btn-submit {
@@ -80,47 +83,48 @@
cursor: not-allowed;
}
/* Status & Feedback Messages */
#status-message {
margin-top: 1rem;
padding: 1rem;
border-radius: var(--border-radius);
/* 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;
}
.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
progress {
width: 100%;
height: 20px;
border-radius: var(--border-radius);
}
.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
#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;
}
.loading {
background-color: #e2e3e5;
color: #383d41;
border: 1px solid #d6d8db;
#logList li {
margin-bottom: 5px;
padding-bottom: 5px;
border-bottom: 1px solid #eee;
}
.spinner {
display: inline-block;
width: 1.5rem;
height: 1.5rem;
border: 3px solid rgba(0,0,0,0.1);
border-radius: 50%;
border-top-color: var(--primary-color);
animation: spin 1s ease-in-out infinite;
vertical-align: middle;
margin-right: 0.5rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
#logList li:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
</style>
</head>
@@ -129,85 +133,177 @@
<div class="upload-container">
<h2>Inventar Batch Upload</h2>
<form id="uploadForm">
<!-- 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>
<!-- Akzeptiert nur CSV Dateien -->
<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>
<!-- multiple erlaubt das Auswählen mehrerer Bilder gleichzeitig -->
<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>
<button type="submit" id="submitBtn" class="btn-submit">Daten hochladen</button>
<!-- ID auf "uploadBtn" geändert -->
<button type="submit" id="uploadBtn" class="btn-submit">Daten hochladen</button>
</form>
<div id="status-message"></div>
<!-- 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('uploadForm').addEventListener('submit', async function(e) {
e.preventDefault(); // Verhindert das Neuladen der Seite
document.getElementById('batchUploadForm').addEventListener('submit', async function(e) {
e.preventDefault();
const form = e.target;
const submitBtn = document.getElementById('submitBtn');
const statusDiv = document.getElementById('status-message');
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');
// UI auf "Laden" setzen
submitBtn.disabled = true;
submitBtn.innerText = 'Wird verarbeitet...';
statusDiv.className = 'loading';
statusDiv.style.display = 'block';
statusDiv.innerHTML = '<div class="spinner"></div> Lade Dateien hoch und verarbeite Bilder... Bitte warten.';
if (!csvInput.files.length) {
alert("Bitte wähle eine CSV-Datei aus.");
return;
}
// FormData sammelt alle Inputs aus dem Formular (csv_file und images)
const formData = new FormData(form);
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 {
// Sende die Daten an den Flask-Endpoint
const response = await fetch('/upload_csv_batch', {
method: 'POST',
body: formData
});
let result;
try {
// Versuche, die Antwort als JSON zu lesen
result = await response.json();
} catch (jsonError) {
// Wenn der Server kein JSON, sondern HTML (z.B. bei einem Python-Crash) sendet
const errorText = await response.text();
console.error("Server hat kein JSON gesendet. Antwort war:", errorText);
throw new Error("Der Server hat einen HTML-Fehler zurückgegeben (Python-Crash oder falscher Pfad). Siehe Konsole.");
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.message || `Server antwortete mit Status ${response.status}`);
}
if (response.ok && result.success) {
// Erfolgreicher Upload
statusDiv.className = 'success';
statusDiv.innerHTML = `
<strong>Erfolg!</strong><br>
${result.message}
`;
form.reset(); // Formular nach Erfolg leeren
} else {
// Fehler vom Server (mit JSON-Fehlermeldung)
statusDiv.className = 'error';
statusDiv.innerHTML = `<strong>Fehler:</strong> ${result.message || 'Ein unbekannter Fehler ist aufgetreten.'}`;
}
} catch (error) {
// Netzwerkfehler oder abgefangener Server-Fehler
statusDiv.className = 'error';
statusDiv.innerHTML = `<strong>Fehler:</strong> ${error.message}`;
console.error('Upload Error:', error);
} finally {
// UI wieder freigeben
submitBtn.disabled = false;
submitBtn.innerText = 'Daten hochladen';
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
}
});
</script>
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>