feat: Add admin functionality to anonymize stored names and implement name aliasing

This commit is contained in:
2026-04-17 21:57:49 +02:00
parent 5cf9a4f1dd
commit b611173ea9
3 changed files with 137 additions and 10 deletions
+88 -7
View File
@@ -206,6 +206,7 @@ PERMISSION_ACTION_ENDPOINTS = {
'register': 'can_manage_users', 'register': 'can_manage_users',
'admin_reset_user_password': 'can_manage_users', 'admin_reset_user_password': 'can_manage_users',
'admin_update_user_permissions': 'can_manage_users', 'admin_update_user_permissions': 'can_manage_users',
'admin_anonymize_names': 'can_manage_users',
'home_admin': 'can_manage_settings', 'home_admin': 'can_manage_settings',
'upload_admin': 'can_manage_settings', 'upload_admin': 'can_manage_settings',
'library_admin': 'can_manage_settings', 'library_admin': 'can_manage_settings',
@@ -1588,14 +1589,25 @@ def _student_card_id_slug(value):
return re.sub(r'[^a-z0-9]+', '', normalized).upper() return re.sub(r'[^a-z0-9]+', '', normalized).upper()
def _name_to_alias(full_name):
"""Convert clear names to deterministic aliases, e.g. Simon Frings -> SimFri."""
text = sanitize_form_value(full_name)
if not text:
return 'User'
parts = [p for p in re.split(r'\s+', text) if p]
if len(parts) >= 2:
return us.build_name_synonym(parts[0], parts[-1])
return us.build_name_synonym(parts[0], '')
def _build_student_card_excel_id(student_name, class_name, row_number, used_ids): 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.""" """Create a stable student-card ID without embedding personal names."""
name_slug = _student_card_id_slug(student_name)
class_slug = _student_card_id_slug(class_name) class_slug = _student_card_id_slug(class_name)
base_parts = [part for part in (class_slug, name_slug) if part] base_parts = [part for part in (class_slug,) if part]
if base_parts: if base_parts:
base_id = f"SC-{'-'.join(base_parts[:2])}" base_id = f"SC-{'-'.join(base_parts[:1])}-ROW-{row_number}"
else: else:
base_id = f"SC-ROW-{row_number}" base_id = f"SC-ROW-{row_number}"
@@ -1715,6 +1727,8 @@ def _upload_student_cards_excel():
student_name = f'{first_name} {last_name}'.strip() student_name = f'{first_name} {last_name}'.strip()
validation_warnings.append((row_number, 'Schülername wurde aus Vorname und Nachname zusammengesetzt')) validation_warnings.append((row_number, 'Schülername wurde aus Vorname und Nachname zusammengesetzt'))
student_name_alias = _name_to_alias(student_name)
if not ausweis_id and not student_name and not class_name: if not ausweis_id and not student_name and not class_name:
continue continue
@@ -1739,7 +1753,7 @@ def _upload_student_cards_excel():
planned_rows.append({ planned_rows.append({
'row_number': row_number, 'row_number': row_number,
'ausweis_id': ausweis_id, 'ausweis_id': ausweis_id,
'student_name': student_name, 'student_name': student_name_alias,
'class_name': class_name, 'class_name': class_name,
'notes': notes, 'notes': notes,
'default_borrow_days': default_borrow_days, 'default_borrow_days': default_borrow_days,
@@ -3172,6 +3186,7 @@ def student_cards_admin():
action = request.form.get('action', 'add') action = request.form.get('action', 'add')
ausweis_id = request.form.get('ausweis_id', '').strip().upper() ausweis_id = request.form.get('ausweis_id', '').strip().upper()
student_name = request.form.get('student_name', '').strip() student_name = request.form.get('student_name', '').strip()
student_name_alias = _name_to_alias(student_name)
default_borrow_days = request.form.get('default_borrow_days', 14) default_borrow_days = request.form.get('default_borrow_days', 14)
class_name = request.form.get('class_name', '').strip() class_name = request.form.get('class_name', '').strip()
notes = request.form.get('notes', '').strip() notes = request.form.get('notes', '').strip()
@@ -3198,7 +3213,7 @@ def student_cards_admin():
else: else:
encrypted_payload = encrypt_document_fields( encrypted_payload = encrypt_document_fields(
{ {
'SchülerName': student_name, 'SchülerName': student_name_alias,
'Klasse': class_name, 'Klasse': class_name,
'Notizen': notes, 'Notizen': notes,
}, },
@@ -3231,7 +3246,7 @@ def student_cards_admin():
try: try:
encrypted_payload = encrypt_document_fields( encrypted_payload = encrypt_document_fields(
{ {
'SchülerName': student_name, 'SchülerName': student_name_alias,
'Klasse': class_name, 'Klasse': class_name,
'Notizen': notes, 'Notizen': notes,
}, },
@@ -7811,6 +7826,72 @@ def admin_update_user_permissions():
return redirect(url_for('user_del')) return redirect(url_for('user_del'))
@app.route('/admin_anonymize_names', methods=['POST'])
def admin_anonymize_names():
"""Anonymize already stored personal names into short aliases."""
if 'username' not in session or not us.check_admin(session['username']):
flash('Nicht autorisierter Zugriff', 'error')
return redirect(url_for('login'))
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
users_col = db['users']
student_cards_col = db['student_cards']
users_updated = 0
cards_updated = 0
for user_doc in users_col.find({}, {'name': 1, 'last_name': 1, 'Username': 1, 'username': 1}):
first = str(user_doc.get('name') or '').strip()
last = str(user_doc.get('last_name') or '').strip()
fallback = str(user_doc.get('Username') or user_doc.get('username') or '').strip()
alias = us.build_name_synonym(first or fallback, last)
result = users_col.update_one(
{'_id': user_doc['_id']},
{'$set': {'name': alias, 'last_name': ''}}
)
if result.modified_count > 0:
users_updated += 1
for card_doc in student_cards_col.find({}, {'SchülerName': 1, 'Klasse': 1, 'Notizen': 1}):
decrypted = _decrypt_student_card_doc(card_doc)
alias = _name_to_alias(decrypted.get('SchülerName', ''))
class_name = sanitize_form_value(decrypted.get('Klasse', ''))
notes = sanitize_form_value(decrypted.get('Notizen', ''))
encrypted_payload = encrypt_document_fields(
{
'SchülerName': alias,
'Klasse': class_name,
'Notizen': notes,
},
STUDENT_CARD_ENCRYPTED_FIELDS,
)
result = student_cards_col.update_one(
{'_id': card_doc['_id']},
{'$set': {'Aktualisiert': datetime.datetime.now(), **encrypted_payload}}
)
if result.modified_count > 0:
cards_updated += 1
flash(
f'Anonymisierung abgeschlossen: {users_updated} Benutzer und {cards_updated} Ausweise aktualisiert.',
'success'
)
except Exception as exc:
app.logger.error(f'Error anonymizing names: {exc}')
flash('Fehler bei der Anonymisierung der Namen.', 'error')
finally:
if client:
client.close()
return redirect(url_for('user_del'))
@app.route('/logs') @app.route('/logs')
def logs(): def logs():
""" """
+10
View File
@@ -17,6 +17,16 @@
<div class="user-management-container"> <div class="user-management-container">
<h2>Benutzer</h2> <h2>Benutzer</h2>
<form method="POST" action="{{ url_for('admin_anonymize_names') }}" class="mb-3">
<button
type="submit"
class="btn btn-outline-danger"
onclick="return confirm('Sollen alle gespeicherten Klarnamen dauerhaft in Synonym-Kuerzel umgewandelt werden?')"
>
Gespeicherte Namen anonymisieren
</button>
</form>
<div class="filter-bar mb-3"> <div class="filter-bar mb-3">
<div class="row g-2 align-items-end"> <div class="row g-2 align-items-end">
<div class="col-md-3"> <div class="col-md-3">
+39 -3
View File
@@ -12,6 +12,7 @@ Provides methods for creating, validating, and retrieving user information.
''' '''
import hashlib import hashlib
import copy import copy
import re
from bson.objectid import ObjectId from bson.objectid import ObjectId
import settings as cfg import settings as cfg
from settings import MongoClient from settings import MongoClient
@@ -24,6 +25,38 @@ def normalize_student_card_id(card_id):
return str(card_id).strip().upper() return str(card_id).strip().upper()
def _clean_name_fragment(value):
cleaned = re.sub(r'[^A-Za-zÄÖÜäöüß]', '', str(value or '').strip())
if not cleaned:
return ''
replacements = {
'ä': 'ae',
'ö': 'oe',
'ü': 'ue',
'ß': 'ss',
'Ä': 'Ae',
'Ö': 'Oe',
'Ü': 'Ue',
}
for old_char, new_char in replacements.items():
cleaned = cleaned.replace(old_char, new_char)
return cleaned
def build_name_synonym(first_name, last_name=''):
"""Build a deterministic, non-personalized short alias like 'SimFri'."""
first = _clean_name_fragment(first_name)
last = _clean_name_fragment(last_name)
if first and last:
return (first[:3] + last[:3]).title()
combined = (first + last)
if not combined:
return 'User'
return combined[:6].title()
ACTION_PERMISSION_KEYS = ( ACTION_PERMISSION_KEYS = (
'can_borrow', 'can_borrow',
'can_insert', 'can_insert',
@@ -361,13 +394,15 @@ def add_user(username, password, name, last_name, is_student=False, student_card
return False return False
permission_defaults = build_default_permission_payload('standard_user') permission_defaults = build_default_permission_payload('standard_user')
name_alias = build_name_synonym(name, last_name)
user_doc = { user_doc = {
'Username': username, 'Username': username,
'Password': hashing(password), 'Password': hashing(password),
'Admin': False, 'Admin': False,
'active_ausleihung': None, 'active_ausleihung': None,
'name': name, 'name': name_alias,
'last_name': last_name, 'last_name': '',
'IsStudent': bool(is_student), 'IsStudent': bool(is_student),
'PermissionPreset': permission_defaults['preset'], 'PermissionPreset': permission_defaults['preset'],
'ActionPermissions': permission_defaults['actions'], 'ActionPermissions': permission_defaults['actions'],
@@ -714,13 +749,14 @@ def update_user_name(username, name, last_name):
bool: True if updated successfully, False otherwise bool: True if updated successfully, False otherwise
""" """
try: try:
name_alias = build_name_synonym(name, last_name)
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
users = db['users'] users = db['users']
result = users.update_one( result = users.update_one(
{'Username': username}, {'Username': username},
{'$set': {'name': name, 'last_name': last_name}} {'$set': {'name': name_alias, 'last_name': ''}}
) )
client.close() client.close()