Compare commits

..

2 Commits

2 changed files with 246 additions and 0 deletions
+236
View File
@@ -1141,6 +1141,236 @@ def _excel_list(value):
return unique
def _student_card_id_slug(value):
"""Build a compact identifier fragment from a name or class value."""
normalized = _normalize_excel_header(value)
if not normalized:
return ''
return re.sub(r'[^a-z0-9]+', '', normalized).upper()
def _build_student_card_excel_id(student_name, class_name, row_number, used_ids):
"""Create a stable student-card ID when the spreadsheet does not provide one."""
name_slug = _student_card_id_slug(student_name)
class_slug = _student_card_id_slug(class_name)
base_parts = [part for part in (class_slug, name_slug) if part]
if base_parts:
base_id = f"SC-{'-'.join(base_parts[:2])}"
else:
base_id = f"SC-ROW-{row_number}"
candidate = base_id
suffix = 2
while candidate in used_ids:
candidate = f"{base_id}-{suffix}"
suffix += 1
used_ids.add(candidate)
return candidate
def _upload_student_cards_excel():
"""Bulk import student cards from Excel with automatic name/class mapping."""
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_admin'))
if not cfg.STUDENT_CARDS_MODULE_ENABLED:
flash('Schülerausweis-Modul ist deaktiviert.', 'error')
return redirect(url_for('home_admin'))
excel_file = request.files.get('student_cards_excel')
if not excel_file or not excel_file.filename:
flash('Bitte eine Excel-Datei auswählen.', 'error')
return redirect(url_for('student_cards_admin'))
filename_lower = excel_file.filename.lower()
if not filename_lower.endswith('.xlsx'):
flash('Nur .xlsx Dateien werden unterstützt.', 'error')
return redirect(url_for('student_cards_admin'))
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('student_cards_admin'))
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('student_cards_admin'))
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('student_cards_admin'))
header_map = {}
for idx, raw_header in enumerate(header_row):
normalized = _normalize_excel_header(raw_header)
if normalized:
header_map[normalized] = idx
synonyms = {
'ausweis_id': ['ausweis_id', 'ausweisid', 'ausweis-id', 'karte', 'kartennummer', 'card_id', 'id'],
'student_name': ['student_name', 'schuelername', 'schülername', 'schueler', 'schüler', 'name', 'vollname', 'vorname_nachname', 'nachname_vorname'],
'first_name': ['vorname', 'first_name', 'firstname'],
'last_name': ['nachname', 'last_name', 'lastname'],
'class_name': ['klasse', 'class', 'class_name', 'jahrgang', 'jahrgangsstufe', 'stufe', 'gruppe', 'asv_klasse'],
'notes': ['notizen', 'notes', 'bemerkungen', 'bemerkung', 'hinweis', 'hinweise'],
'default_borrow_days': ['standard_ausleihdauer', 'ausleihdauer', 'borrow_days', 'tage', 'leihtage', 'max_borrow_days'],
}
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
mapped_indices = {
'ausweis_id': col_index('ausweis_id'),
'student_name': col_index('student_name'),
'first_name': col_index('first_name'),
'last_name': col_index('last_name'),
'class_name': col_index('class_name'),
'notes': col_index('notes'),
'default_borrow_days': col_index('default_borrow_days'),
}
validation_only = (request.form.get('excel_action') or '').strip().lower() == 'validate'
max_rows = 15000
planned_rows = []
validation_errors = []
validation_warnings = []
existing_ids = set()
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
try:
db = client[cfg.MONGODB_DB]
student_cards = db['student_cards']
existing_ids.update(
str(card.get('AusweisId', '')).strip().upper()
for card in student_cards.find({}, {'AusweisId': 1})
if card.get('AusweisId')
)
processed_rows = 0
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:
validation_errors.append((row_number, f'Maximal {max_rows} Zeilen pro Datei erlaubt'))
break
def val(key):
idx = mapped_indices.get(key)
if idx is None or idx >= len(row_values):
return None
return row_values[idx]
ausweis_id = sanitize_form_value(val('ausweis_id'))
student_name = sanitize_form_value(val('student_name'))
first_name = sanitize_form_value(val('first_name'))
last_name = sanitize_form_value(val('last_name'))
class_name = sanitize_form_value(val('class_name'))
notes = sanitize_form_value(val('notes'))
default_borrow_days = _excel_int(val('default_borrow_days')) or cfg.STUDENT_DEFAULT_BORROW_DAYS
if not student_name and first_name and last_name:
student_name = f'{first_name} {last_name}'.strip()
validation_warnings.append((row_number, 'Schülername wurde aus Vorname und Nachname zusammengesetzt'))
if not ausweis_id and not student_name and not class_name:
continue
row_errors = []
if not student_name:
row_errors.append('Schülername fehlt')
if not ausweis_id and student_name:
ausweis_id = _build_student_card_excel_id(student_name, class_name, row_number, existing_ids)
validation_warnings.append((row_number, f'Ausweis-ID wurde automatisch erzeugt: {ausweis_id}'))
elif ausweis_id:
ausweis_id = str(ausweis_id).strip().upper()
if ausweis_id in existing_ids:
row_errors.append(f'Ausweis-ID {ausweis_id} existiert bereits')
else:
existing_ids.add(ausweis_id)
if row_errors:
validation_errors.append((row_number, '; '.join(row_errors)))
continue
planned_rows.append({
'row_number': row_number,
'ausweis_id': ausweis_id,
'student_name': student_name,
'class_name': class_name,
'notes': notes,
'default_borrow_days': default_borrow_days,
})
finally:
client.close()
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('student_cards_admin'))
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}'
flash(f'Validierung erfolgreich: {len(planned_rows)} Ausweise würden importiert.{warning_text}', 'success')
return redirect(url_for('student_cards_admin'))
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
try:
db = client[cfg.MONGODB_DB]
student_cards = db['student_cards']
created_total = 0
for row in planned_rows:
encrypted_payload = encrypt_document_fields(
{
'SchülerName': row['student_name'],
'Klasse': row['class_name'],
'Notizen': row['notes'],
},
STUDENT_CARD_ENCRYPTED_FIELDS
)
student_cards.insert_one({
'AusweisId': row['ausweis_id'],
'StandardAusleihdauer': int(row['default_borrow_days']),
'Erstellt': datetime.datetime.now(),
**encrypted_payload,
})
created_total += 1
except Exception as exc:
app.logger.error(f'Error importing student cards from Excel: {exc}')
flash(f'Fehler beim Import der Bibliotheksausweise: {exc}', 'error')
return redirect(url_for('student_cards_admin'))
finally:
client.close()
if validation_warnings:
warning_details = '; '.join([f'Zeile {n}: {msg}' for n, msg in validation_warnings[:10]])
flash(f'Excel-Import erfolgreich: {created_total} Ausweise importiert. Hinweise: {warning_details}', 'warning')
else:
flash(f'Excel-Import erfolgreich: {created_total} Ausweise importiert.', 'success')
return redirect(url_for('student_cards_admin'))
def _upload_excel_items(scope='inventory'):
"""Bulk import inventory/library items from Excel with validation-first workflow."""
if 'username' not in session:
@@ -1463,6 +1693,12 @@ def upload_library_excel():
return _upload_excel_items(scope='library')
@app.route('/upload_student_cards_excel', methods=['POST'])
def upload_student_cards_excel():
"""Bulk import student cards from Excel."""
return _upload_student_cards_excel()
@app.route('/uploads/<filename>')
def uploaded_file(filename):
"""
+10
View File
@@ -237,6 +237,16 @@
</div>
</div>
<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 Bibliotheksausweise</h3>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>-Datei hoch, zum Beispiel aus <strong>ASV (Amtliche Schuldaten)</strong>. Erkannt werden automatisch Spalten wie <strong>Name</strong>, <strong>Klasse</strong>, <strong>Ausweis-ID</strong>, <strong>Notizen</strong> und <strong>Standard-Ausleihdauer</strong>. Fehlt die Ausweis-ID, wird sie automatisch aus Name und Klasse erzeugt.</p>
<form method="POST" action="{{ url_for('upload_student_cards_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<input type="file" name="student_cards_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">Ausweise importieren</button>
</form>
</div>
<!-- Add/Edit Form -->
<div class="student-card-form">
<h2>{% if edit_mode %}Ausweis bearbeiten{% else %}Neuer Schülerausweis{% endif %}</h2>