Initial implementation of a studentcard abgleich.
Release Inventarsystem / release-docker (push) Successful in 2m15s

This commit is contained in:
2026-08-18 18:10:44 +02:00
parent df0f7d3066
commit bd7ef61f5a
2 changed files with 169 additions and 76 deletions
+122 -69
View File
@@ -2150,17 +2150,15 @@ def generate_ausweis_id_excel(existing_ids_set):
else:
print(f"Already found: {new_id}, trying another...")
def _upload_student_cards_excel():
"""Bulk import student cards from Excel with automatic name/class mapping."""
"""Bulk import student cards with optional school year rollover (Abgleich)."""
if 'username' not in session:
flash('Nicht angemeldet.', 'error')
return redirect(url_for('login'))
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['actions'].get('can_manage_user', False):
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
flash('Ihnen fehlen die nötigen Berechtigungen.', 'error')
return redirect(url_for('library_view'))
if not cfg.MODULES.is_enabled('student_cards'):
@@ -2177,6 +2175,9 @@ def _upload_student_cards_excel():
flash('Nur .xlsx oder .csv Dateien werden unterstützt.', 'error')
return redirect(url_for('student_cards_admin'))
# CHECKBOX / SCHALTER: Schuljahres-Abgleich aktiviert?
rollover_mode = request.form.get('rollover_mode') in ['true', '1', 'on']
try:
header_row, data_rows = _load_tabular_upload(excel_file)
except Exception as exc:
@@ -2191,12 +2192,14 @@ def _upload_student_cards_excel():
synonyms = {
'ausweis_id': ['ausweis_id', 'ausweisid', 'ausweis-id', 'karte', 'kartennummer', 'card_id', 'id'],
'ausweis_ident': ['lokales Differenzierungsmerkmal', 'lokales differenzierungsmerkmal', 'lokales_differenzierungsmerkmal', 'ausweis_ident', 'differenzierungsmerkmal'],
'first_name': ['vorname', 'first_name', 'firstname', 'rufname', 'Vorname'],
'last_name': ['nachname', 'last_name', 'lastname', 'Nachname'],
'class_name': ['klasse', 'class', 'class_name', 'jahrgang', 'jahrgangsstufe', 'stufe', 'gruppe', 'asv_klasse', 'Jahrgang', 'Klasse'],
'notes': ['notizen', 'notes', 'bemerkungen', 'bemerkung', 'hinweis', 'hinweise', 'Notizen', 'Bemerkung', 'Hinweis', 'Hinweise'],
'default_borrow_days': ['standard_ausleihdauer', 'ausleihdauer', 'borrow_days', 'tage', 'leihtage', 'max_borrow_days', 'Ausleihdauer'],
'ausweis_ident': ['lokales differenzierungsmerkmal', 'lokales_differenzierungsmerkmal', 'ausweis_ident',
'differenzierungsmerkmal'],
'first_name': ['vorname', 'first_name', 'firstname', 'rufname'],
'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):
@@ -2217,7 +2220,7 @@ def _upload_student_cards_excel():
}
validation_only = (request.form.get('excel_action') or '').strip().lower() == 'validate'
max_rows = 1500
max_rows = 15000
planned_rows = []
validation_errors = []
@@ -2227,13 +2230,11 @@ def _upload_student_cards_excel():
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
try:
db = client[cfg.MONGODB_DB]
student_cards_cursor = db['student_cards'].find({}, {'AusweisId': 1})
existing_ids.update(
str(card.get('AusweisId', '')).strip().upper()
for card in student_cards_cursor
if card.get('AusweisId')
)
raw_db_cards = list(db['student_cards'].find())
for card in raw_db_cards:
if card.get('AusweisId'):
existing_ids.add(str(card.get('AusweisId')).strip().upper())
processed_rows = 0
for row_number, row_values in enumerate(data_rows, start=2):
@@ -2250,43 +2251,22 @@ def _upload_student_cards_excel():
ausweis_id = sanitize_form_value(val('ausweis_id'))
ausweis_ident = sanitize_form_value(val('ausweis_ident'))
class_name = sanitize_form_value(val('class_name'))
class_name = sanitize_form_value(val('class_name')) or ""
notes = sanitize_form_value(val('notes'))
# Ausleihdauer extrahieren und standardmäßig auf 14 setzen
default_borrow_days = _excel_int(val('default_borrow_days'))
if not default_borrow_days:
default_borrow_days = 14
# Vor- und Nachname sicher auslesen und zusammensetzen
default_borrow_days = _excel_int(val('default_borrow_days')) or 14
first_name = sanitize_form_value(val('first_name')) or ""
last_name = sanitize_form_value(val('last_name')) or ""
student_name = f"{first_name} {last_name}".strip()
# Leere Zeilen überspringen
if not ausweis_id and not student_name and not class_name:
continue
row_errors = []
if not student_name:
row_errors.append('Vorname und Nachname fehlen')
# Logik für die Haupt-AusweisID
if not ausweis_id and student_name:
ausweis_id = generate_ausweis_id(existing_ids)
validation_warnings.append((row_number, f'Ausweis-ID wurde automatisch erzeugt: {ausweis_id}'))
existing_ids.add(ausweis_id.upper())
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 not ausweis_ident:
ausweis_ident = ausweis_id
if row_errors:
validation_errors.append((row_number, '; '.join(row_errors)))
continue
@@ -2296,65 +2276,138 @@ def _upload_student_cards_excel():
'ausweis_id': ausweis_id,
'ausweis_ident': ausweis_ident,
'student_name': student_name,
'first_name': first_name,
'last_name': last_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_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'))
matched_db_doc_ids = set()
rows_to_create = []
matched_count = 0
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
try:
db = client[cfg.MONGODB_DB]
student_cards = db['student_cards']
if rollover_mode:
decrypted_db_cards = []
for doc in raw_db_cards:
dec_name = dp.decrypt_text(doc.get('SchülerName')) if doc.get('SchülerName') else ""
dec_class = dp.decrypt_text(doc.get('Klasse')) if doc.get('Klasse') else ""
dec_ident = doc.get('AusweisIdent') or ""
if dec_ident and dec_ident.startswith("gAAAAA"):
try:
dec_ident = dp.decrypt_text(dec_ident)
except:
pass
decrypted_db_cards.append({
'_id': doc['_id'],
'AusweisId': str(doc.get('AusweisId', '')).strip().upper(),
'AusweisIdent': str(dec_ident or '').strip().upper(),
'SchülerName': str(dec_name or '').strip().lower(),
'Klasse': str(dec_class or '').strip().lower(),
})
for excel_row in planned_rows:
ex_ident = str(excel_row['ausweis_ident'] or '').strip().upper()
ex_name = str(excel_row['student_name'] or '').strip().lower()
ex_class = str(excel_row['class_name'] or '').strip().lower()
match_found = None
for db_card in decrypted_db_cards:
if db_card['_id'] in matched_db_doc_ids:
continue
ident_matches = ex_ident and (ex_ident == db_card['AusweisIdent'])
secondary_matches = (ex_name and ex_name == db_card['SchülerName']) and \
(ex_class and ex_class == db_card['Klasse'])
if ident_matches or secondary_matches:
match_found = db_card
break
if match_found:
matched_db_doc_ids.add(match_found['_id'])
matched_count += 1
else:
rows_to_create.append(excel_row)
db_ids_to_delete = [
doc['_id'] for doc in raw_db_cards
if doc['_id'] not in matched_db_doc_ids
]
else:
rows_to_create = planned_rows
db_ids_to_delete = []
if validation_only:
flash(
f'Validierung erfolgreich ({len(planned_rows)} Excel-Zeilen). '
f'Abgleich: {matched_count} unverändert, {len(rows_to_create)} neu, {len(db_ids_to_delete)} zum Löschen.',
'success'
)
return redirect(url_for('student_cards_admin'))
student_cards_col = db['student_cards']
deleted_count = 0
if db_ids_to_delete:
res = student_cards_col.delete_many({'_id': {'$in': db_ids_to_delete}})
deleted_count = res.deleted_count
created_total = 0
for row in planned_rows:
for row in rows_to_create:
row_ausweis_id = row['ausweis_id']
if not row_ausweis_id:
row_ausweis_id = generate_ausweis_id(existing_ids)
existing_ids.add(row_ausweis_id.upper())
row_ausweis_ident = row['ausweis_ident']
if not row_ausweis_ident:
random_chars = "".join(random.choices(string.ascii_uppercase + string.digits, k=5))
row_ausweis_ident = f"LD-{random_chars}"
encrypted_payload = encrypt_document_fields(
{
'ausweis_ident': row['ausweis_ident'],
'SchülerName': row['student_name'],
'Klasse': row['class_name'],
'Notizen': row['notes'],
},
STUDENT_CARD_ENCRYPTED_FIELDS
)
student_cards.insert_one({
'AusweisId': row['ausweis_id'],
student_cards_col.insert_one({
'AusweisId': row_ausweis_id,
'AusweisIdent': row_ausweis_ident,
'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', 'error')
app.logger.error(f'Error importing student cards: {exc}')
flash('Fehler beim Verarbeiten der Bibliotheksausweise.', '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')
if rollover_mode:
flash(
f'Schuljahres-Abgleich erfolgreich: {matched_count} Schüler beibehalten, '
f'{created_total} neu hinzugefügt, {deleted_count} alte Einträge gelöscht.',
'success'
)
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:
+47 -7
View File
@@ -45,6 +45,33 @@
justify-content: space-between;
}
.rollover-box {
background: #fff3cd;
border: 1px solid #ffeeba;
border-radius: 6px;
padding: 10px 12px;
margin-top: 4px;
}
.rollover-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 13px;
color: #856404;
font-weight: 700;
margin: 0;
}
.rollover-hint {
display: block;
margin-top: 4px;
font-size: 11px;
color: #856404;
line-height: 1.3;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -303,7 +330,7 @@
placeholder="z.B. Max Mustermann">
</div>
<div class="form-group">
<label for="class_name">Klasse (optional)</label>
<label for="class_name">Klasse</label>
<input type="text" id="class_name" name="class_name" list="class_list"
value="{{ form_data.get('class_name', '') }}"
placeholder="z.B. 10A (Tippen oder Auswählen)">
@@ -318,22 +345,22 @@
<div class="form-row">
<div class="form-group">
<label for="ausweis_id">Ausweis-ID (optional)</label>
<label for="ausweis_id">Ausweis-ID</label>
<input type="text" id="ausweis_id" name="ausweis_id"
value="{{ form_data.get('ausweis_id', '') }}"
placeholder="z.B. SIS2024001">
</div>
<div class="form-group">
<label for="ausweis_ident">Lokales Differenzierungsmerkmal (optional aber empfohlen)</label>
<label for="ausweis_ident">Lokales Differenzierungsmerkmal</label>
<input type="text" id="ausweis_ident" name="ausweis_ident"
value="{{ form_data.get('ausweis_ident', '') }}"
placeholder="z.B. xw5oo123bbe">
placeholder="z.B. xw5oo123bbe">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="default_borrow_days">Standard Ausleihdauer (Tage) (optional)</label>
<label for="default_borrow_days">Standard Ausleihdauer (Tage) *</label>
<input type="number" id="default_borrow_days" name="default_borrow_days"
min="1" max="365" required
value="{{ form_data.get('default_borrow_days', config.get('default', 14)) }}">
@@ -359,16 +386,29 @@
</form>
</div>
<!-- Excel-Import -->
<!-- Excel-Import mit Abgleich (Rollover-Modus) -->
<div class="import-card">
<div>
<h3 style="margin:0 0 8px 0;">Excel-Import</h3>
<p style="margin:0 0 15px 0; color:#555; font-size:13px; line-height:1.4;">
<p style="margin:0 0 12px 0; color:#555; font-size:13px; line-height:1.4;">
Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch (z. B. aus <strong>ASV</strong>). Erkannt werden Name, Nachname, Klasse, Ausweis-ID, lokales Differenzierungsmerkmal, Notizen & Ausleihdauer.
</p>
</div>
<form method="POST" action="{{ url_for('upload_student_cards_excel') }}" enctype="multipart/form-data" style="display:flex; flex-direction:column; gap:12px;">
<input type="file" name="student_cards_excel" accept=".xlsx,.csv" required style="font-size:13px;">
<!-- Rollover / Abgleich Option -->
<div class="rollover-box">
<label class="rollover-label">
<input type="checkbox" name="rollover_mode" value="true">
<span>Rollover-Modus (Abgleich / Destruktiv)</span>
</label>
<span class="rollover-hint">
⚠️ <strong>Warnung:</strong> Nicht mehr vorhandene Ausweise/Schüler werden beim Import entfernt bzw. abgeglichen.
</span>
</div>
<div style="display:flex; gap:8px;">
<button type="submit" class="btn btn-secondary" name="excel_action" value="validate" style="flex:1;">Validieren</button>
<button type="submit" class="btn btn-primary" name="excel_action" value="import" style="flex:1;">Importieren</button>