changes to the mahnungssystem in generell and especialy for the overview
Release Inventarsystem / release-docker (push) Successful in 2m16s

This commit is contained in:
2026-08-23 19:05:59 +02:00
parent ffab9b7fdf
commit 683059f3de
2 changed files with 498 additions and 426 deletions
+318 -331
View File
@@ -1339,14 +1339,18 @@ def update_appointment_statuses():
Diese Funktion wird jede Minute ausgeführt und überprüft: Diese Funktion wird jede Minute ausgeführt und überprüft:
- Geplante Termine, die aktiviert werden sollten - Geplante Termine, die aktiviert werden sollten
- Aktive Termine, die beendet werden sollten - Aktive Termine, die beendet werden sollten
- Überfällige Bibliotheksartikel (Mahnlauf & Admin-Benachrichtigungen)
""" """
current_time = datetime.datetime.now(ZoneInfo("Europe/Berlin")) current_time = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
current_time_naive = current_time.replace(tzinfo=None)
client = None
try: try:
# Hole alle Termine mit Status 'planned' oder 'active'
client = MongoClient(MONGODB_HOST, MONGODB_PORT) client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB] db = client[MONGODB_DB]
ausleihungen = db['ausleihungen'] ausleihungen = db['ausleihungen']
items_col = db['items']
student_cards_col = db['student_cards']
# Finde alle Termine, die status updates benötigen # Finde alle Termine, die status updates benötigen
appointments_to_check = list(ausleihungen.find({ appointments_to_check = list(ausleihungen.find({
@@ -1370,9 +1374,7 @@ def update_appointment_statuses():
extra_fields = {} extra_fields = {}
# --- Conflict resolver: planned → active transition --- # --- Conflict resolver: planned → active transition ---
# Check if the physical item is already borrowed by someone else
if old_status == 'planned' and new_status == 'active': if old_status == 'planned' and new_status == 'active':
items_col = db['items']
item_id_str = appointment.get('Item') item_id_str = appointment.get('Item')
conflict_detected = False conflict_detected = False
conflict_note = '' conflict_note = ''
@@ -1387,7 +1389,6 @@ def update_appointment_statuses():
item_name = item_doc.get('Name', item_id_str) item_name = item_doc.get('Name', item_id_str)
activation_item_name = item_name activation_item_name = item_name
total_exemplare = int(item_doc.get('Exemplare', 1)) total_exemplare = int(item_doc.get('Exemplare', 1))
# Count how many active (non-planned) borrows currently hold this item
active_borrows = ausleihungen.count_documents({ active_borrows = ausleihungen.count_documents({
'Item': item_id_str, 'Item': item_id_str,
'Status': 'active', 'Status': 'active',
@@ -1396,7 +1397,6 @@ def update_appointment_statuses():
if active_borrows >= total_exemplare or item_doc.get('Verfuegbar') is False: if active_borrows >= total_exemplare or item_doc.get('Verfuegbar') is False:
conflict_detected = True conflict_detected = True
borrower = item_doc.get('User', 'unbekannter Benutzer') borrower = item_doc.get('User', 'unbekannter Benutzer')
item_name = item_doc.get('Name', item_id_str)
conflict_note = ( conflict_note = (
f"Gegenstand '{item_name}' war beim Aktivieren von " f"Gegenstand '{item_name}' war beim Aktivieren von "
f"'{appointment.get('User', '?')}' bereits ausgeliehen " f"'{appointment.get('User', '?')}' bereits ausgeliehen "
@@ -1405,13 +1405,10 @@ def update_appointment_statuses():
extra_fields['ConflictDetected'] = True extra_fields['ConflictDetected'] = True
extra_fields['ConflictNote'] = conflict_note extra_fields['ConflictNote'] = conflict_note
extra_fields['ConflictAt'] = current_time extra_fields['ConflictAt'] = current_time
conflict_log = ( app.logger.warning(
f" [KONFLIKT] Termin {appointment['_id']}: " f" [KONFLIKT] Termin {appointment['_id']}: planned → active, aber {conflict_note}"
f"planned → active, aber {conflict_note}"
) )
app.logger.warning(conflict_log)
else: else:
# No conflict — clear any previously stored conflict flag
extra_fields['ConflictDetected'] = False extra_fields['ConflictDetected'] = False
extra_fields['ConflictNote'] = '' extra_fields['ConflictNote'] = ''
except Exception as conflict_err: except Exception as conflict_err:
@@ -1432,7 +1429,6 @@ def update_appointment_statuses():
updated_count += 1 updated_count += 1
if new_status == 'active': if new_status == 'active':
activated_count += 1 activated_count += 1
# Make item unshareable if no conflict is detected
if old_status == 'planned' and appointment.get('Item') and not extra_fields.get('ConflictDetected', False): if old_status == 'planned' and appointment.get('Item') and not extra_fields.get('ConflictDetected', False):
try: try:
it.update_item_status(str(appointment.get('Item')), False, activation_user) it.update_item_status(str(appointment.get('Item')), False, activation_user)
@@ -1441,14 +1437,12 @@ def update_appointment_statuses():
elif new_status == 'completed': elif new_status == 'completed':
completed_count += 1 completed_count += 1
# Make item available again
if appointment.get('Item'): if appointment.get('Item'):
try: try:
it.update_item_status(str(appointment.get('Item')), True) it.update_item_status(str(appointment.get('Item')), True)
except Exception as e: except Exception as e:
app.logger.warning(f"Could not update item status to True for {appointment['_id']}: {e}") app.logger.warning(f"Could not update item status to True for {appointment['_id']}: {e}")
# Create activation notification even if another worker already updated the status.
if old_status == 'planned' and new_status == 'active' and activation_user: if old_status == 'planned' and new_status == 'active' and activation_user:
try: try:
_create_notification( _create_notification(
@@ -1456,9 +1450,7 @@ def update_appointment_statuses():
audience='user', audience='user',
notif_type='appointment_activated', notif_type='appointment_activated',
title='Reservierung ist jetzt aktiv', title='Reservierung ist jetzt aktiv',
message=( message=f"Deine geplante Ausleihe für {activation_item_name} startet jetzt.",
f"Deine geplante Ausleihe für {activation_item_name} startet jetzt."
),
target_user=activation_user, target_user=activation_user,
reference={ reference={
'appointment_id': str(appointment.get('_id')), 'appointment_id': str(appointment.get('_id')),
@@ -1472,108 +1464,131 @@ def update_appointment_statuses():
app.logger.warning( app.logger.warning(
f"Failed to create activation notification for {appointment.get('_id')}: {notif_err}" f"Failed to create activation notification for {appointment.get('_id')}: {notif_err}"
) )
# -----------------------------------------------------------------
# Mahnlauf für Bibliotheksartikel (Prüfung auf Überfälligkeit)
# -----------------------------------------------------------------
elif it.is_library_item(appointment.get('Item')): elif it.is_library_item(appointment.get('Item')):
current_time = datetime.datetime.now(ZoneInfo("Europe/Berlin")) appt = appointment # Verwende das aktuelle Dokument aus der Schleife
if appt.get('Status') != 'active':
continue
try: due_date_obj = appt.get('DueDate')
client = MongoClient(MONGODB_HOST, MONGODB_PORT) if not due_date_obj:
db = client[MONGODB_DB] continue
ausleihungen = db['ausleihungen']
users_col = db['users']
items_col = db['items']
# Finde alle aktiven Ausleihungen, die in der Vergangenheit fällig waren due_date_naive = due_date_obj.replace(tzinfo=None) if due_date_obj.tzinfo else due_date_obj
overdue_appointments = list(ausleihungen.find({ days_overdue = (current_time_naive - due_date_naive).days
'Status': 'active',
'DueDate': {'$lt': current_time}
}))
for appt in overdue_appointments:
days_overdue = (current_time - appt.get('DueDate', current_time)).days
mahnstufe = appt.get('Mahnstufe', 0) mahnstufe = appt.get('Mahnstufe', 0)
target_user = str(appt.get('User', '')).strip() raw_user = str(appt.get('User', '')).strip()
# Entschlüsselung der AusweisId
decrypted_user = decrypt_text(raw_user) if 'decrypt_text' in globals() else raw_user
target_ausweis_id = decrypted_user if decrypted_user else raw_user
item_id = appt.get('Item') item_id = appt.get('Item')
# 1. Objektdetails holen (für den E-Mail-Text) if not target_ausweis_id:
continue
# In student_cards suchen
card = student_cards_col.find_one({'AusweisId': target_ausweis_id})
if not card and raw_user != target_ausweis_id:
card = student_cards_col.find_one({'AusweisId': raw_user})
if not card:
app.logger.warning(f"Mahnlauf: Kein Schülerausweis für AusweisId '{target_ausweis_id}' gefunden. Überspringe.")
continue
student_card = _decrypt_student_card_doc(card) if '_decrypt_student_card_doc' in globals() else card
student_name = student_card.get('SchülerName', target_ausweis_id)
student_class = student_card.get('Klasse', '—')
# Gegenstandsdetails laden
item_name = "Unbekannter Artikel" item_name = "Unbekannter Artikel"
if item_id: if item_id:
try: try:
item_doc = items_col.find_one({'_id': ObjectId(item_id)}) item_doc = items_col.find_one({'_id': ObjectId(str(item_id))})
if item_doc: if item_doc:
item_name = item_doc.get('Name', str(item_id)) item_name = item_doc.get('Name', str(item_id))
code_4 = item_doc.get('Code_4')
if code_4:
item_name = f"{item_name} ({code_4})"
except Exception: except Exception:
pass pass
# 2. Nutzerdetails holen (für die E-Mail-Adresse) # STUFE 2: >= 28 Tage überfällig -> Ausweis sperren, Stufe 2 setzen & Admins benachrichtigen
user_doc = users_col.find_one({'username': target_user}) # Feldnamen ggf. anpassen (z.B. '_id') if days_overdue >= 28 and mahnstufe < 2:
if not user_doc:
app.logger.warning(f"Nutzer '{target_user}' für Mahnung nicht gefunden.")
continue
user_email = user_doc.get('email')
# 3. Eskalationsstufen prüfen
if days_overdue >= 14 and days_overdue < 28 and mahnstufe == 0:
# STUFE 1: Erste Mahnung
ausleihungen.update_one(
{'_id': appt['_id']},
{'$set': {'Mahnstufe': 1, 'LastUpdated': current_time}}
)
_create_notification(
db, audience='user', notif_type='warning',
title='Erinnerung: Ausleihe überfällig',
message=f'Deine Ausleihe für "{item_name}" ist seit {days_overdue} Tagen überfällig.',
target_user=target_user, severity='warning'
)
if user_email:
subject = "1. Mahnung: Rückgabe überfällig"
note = (f"Hallo {target_user},<br><br>"
f"bitte beachte, dass die Ausleihe für den Artikel <b>{item_name}</b> "
f"seit {days_overdue} Tagen überfällig ist. Bitte bringe den Artikel zeitnah zurück.")
send(email=user_email, subject=subject, note=note, sender="Bibliotheksverwaltung")
app.logger.info(f"1. Mahnung an {user_email} gesendet.")
elif days_overdue >= 28 and mahnstufe == 1:
# STUFE 2: Letzte Mahnung, Sperrung und Report
ausleihungen.update_one( ausleihungen.update_one(
{'_id': appt['_id']}, {'_id': appt['_id']},
{'$set': {'Mahnstufe': 2, 'LastUpdated': current_time}} {'$set': {'Mahnstufe': 2, 'LastUpdated': current_time}}
) )
# Nutzerkonto für weitere Ausleihen sperren block_reason = f'System-Sperre: Ausleihe von "{item_name}" ist seit {days_overdue} Tagen überfällig.'
users_col.update_one(
{'_id': user_doc['_id']}, student_cards_col.update_one(
{'_id': card['_id']},
{'$set': { {'$set': {
'is_blocked': True, 'is_blocked': True,
'block_reason': f'System-Sperre: Ausleihe von "{item_name}" {days_overdue} Tage überfällig.' 'block_reason': block_reason,
'Aktualisiert': current_time
}} }}
) )
title = 'Ausweis gesperrt (2. Mahnung)'
body = f'Der Schülerausweis von {student_name} (Klasse {student_class}) wurde wegen "{item_name}" automatisch gesperrt.'
target_url = '/mahnungen_admin'
# 1. In-App Notification (Admin-Tab)
if '_create_notification' in globals():
try:
_create_notification( _create_notification(
db, audience='user', notif_type='error', db, audience='admin', notif_type='error',
title='Konto gesperrt - 2. Mahnung', title=title, message=body, severity='critical',
message=f'Aufgrund der starken Überfälligkeit von "{item_name}" wurde dein Konto vorübergehend gesperrt.', unique_key=f"mahnlauf:st2:{appt['_id']}"
target_user=target_user, severity='critical' )
except Exception as n_err:
app.logger.warning(f"Fehler beim Erstellen der Admin-Notif (Stufe 2): {n_err}")
# 2. Web-Push Notification für Admins
if 'send_push_to_all_admins' in globals():
try:
send_push_to_all_admins(title=title, body=body, url=target_url)
except Exception as p_err:
app.logger.error(f"Fehler beim Senden der Admin-Push (Stufe 2): {p_err}")
app.logger.warning(f"Mahnstufe 2 & Ausweis-Sperre für Schülerausweis '{student_name}' ({target_ausweis_id}) gesetzt.")
# STUFE 1: >= 14 Tage überfällig -> Stufe 1 setzen & Admins benachrichtigen
elif days_overdue >= 14 and mahnstufe == 0:
ausleihungen.update_one(
{'_id': appt['_id']},
{'$set': {'Mahnstufe': 1, 'LastUpdated': current_time}}
) )
if user_email: title = '1. Mahnung erreicht'
subject = "WICHTIG: Kontosperrung & 2. Mahnung" body = f'Ausleihe überfällig: {student_name} (Klasse {student_class}) hat "{item_name}" seit {days_overdue} Tagen nicht zurückgegeben.'
note = (f"Hallo {target_user},<br><br>" target_url = '/mahnungen_admin'
f"deine Ausleihe für den Artikel <b>{item_name}</b> ist nun seit {days_overdue} Tagen überfällig. "
f"Dies ist ein automatischer Bericht über deinen Status: <b>Dein Konto wurde soeben für neue Reservierungen gesperrt.</b><br><br>"
f"Die Sperre wird erst aufgehoben, sobald der Artikel im System als zurückgegeben gemeldet wurde.")
send(email=user_email, subject=subject, note=note, sender="Bibliotheksverwaltung")
app.logger.warning(
f"Nutzer {target_user} gesperrt. 2. Mahnung an {user_email} gesendet.")
client.close() # 1. In-App Notification (Admin-Tab)
if '_create_notification' in globals():
try:
_create_notification(
db, audience='admin', notif_type='warning',
title=title, message=body, severity='warning',
unique_key=f"mahnlauf:st1:{appt['_id']}"
)
except Exception as n_err:
app.logger.warning(f"Fehler beim Erstellen der Admin-Notif (Stufe 1): {n_err}")
except Exception as e: # 2. Web-Push Notification für Admins
app.logger.error(f"Fehler bei der automatischen Mahnlauf-Prüfung: {e}") if 'send_push_to_all_admins' in globals():
try:
send_push_to_all_admins(title=title, body=body, url=target_url)
except Exception as p_err:
app.logger.error(f"Fehler beim Senden der Admin-Push (Stufe 1): {p_err}")
client.close() app.logger.info(f"Mahnstufe 1 für Schülerausweis '{student_name}' ({target_ausweis_id}) gesetzt.")
if updated_count > 0: if updated_count > 0:
app.logger.warning( app.logger.warning(
@@ -1582,6 +1597,9 @@ def update_appointment_statuses():
except Exception as e: except Exception as e:
app.logger.error(f"Automatic appointment status update failed: {e}") app.logger.error(f"Automatic appointment status update failed: {e}")
finally:
if client:
client.close()
# Initialize scheduler instances # Initialize scheduler instances
@@ -3606,205 +3624,18 @@ def test_mahnungen():
# Testdaten (Mock-Daten), um alle if/else Bedingungen im HTML zu testen # Testdaten (Mock-Daten), um alle if/else Bedingungen im HTML zu testen
generate_test_ausleihen() generate_test_ausleihen()
create_return_reminders() create_return_reminders()
"""Admin overview for overdue library items (Mahnungen)."""
if 'username' not in session:
flash(
'Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!',
'error')
return redirect(url_for('login'))
current_permissions = us.get_effective_permissions(session['username'])
# Hier nutzen wir beispielhaft die library_loans_admin Berechtigung
if not current_permissions['pages'].get('library_loans_admin', False):
flash(
'Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!',
'error')
return redirect(url_for('library_view'))
if not cfg.MODULES.is_enabled('library'):
flash('Bibliotheks-Modul ist deaktiviert.', 'error')
return redirect(url_for('home_admin'))
def fmt_dt(dt):
try:
return dt.strftime('%d.%m.%Y') if dt else 'Unbekannt'
except Exception:
return str(dt) if dt else 'Unbekannt'
def safe_decrypt(val):
if not val or not isinstance(val, str):
return val or ''
try:
return decrypt_text(val)
except Exception:
return val
current_time = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
current_time_naive = current_time.replace(tzinfo=None)
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
ausleihungen_col = db['ausleihungen']
items_col = db['items']
users_col = db['users']
student_cards_col = db['student_cards']
# Überfällige Ausleihen abrufen (Status 'active' und DueDate in der Vergangenheit)
overdue_records = list(ausleihungen_col.find({
'Status': 'active',
'DueDate': {'$lt': current_time}
}).sort('DueDate', 1))
overdue_list = []
if overdue_records:
# Bulk-Lookups zur Vermeidung von N+1 Queries
item_ids = []
for r in overdue_records:
i_id = r.get('Item')
if i_id:
try:
item_ids.append(ObjectId(str(i_id)))
except Exception:
item_ids.append(str(i_id))
items_cursor = items_col.find({
'_id': {'$in': item_ids}
}, {'Name': 1, 'Code_4': 1})
item_map = {str(item['_id']): item for item in items_cursor}
raw_users = [r.get('User') for r in overdue_records if r.get('User')]
users_cursor = users_col.find({'username': {'$in': raw_users}}, {'username': 1, 'is_blocked': 1})
user_block_map = {u.get('username'): u.get('is_blocked', False) for u in users_cursor}
class_map = {}
all_cards = list(student_cards_col.find({}, {'SchülerName': 1, 'Klasse': 1, 'AusweisId': 1}))
for card in all_cards:
raw_cls = card.get('Klasse')
dec_cls = safe_decrypt(raw_cls)
if not dec_cls:
continue
raw_name = card.get('SchülerName')
dec_name = safe_decrypt(raw_name)
ausweis_id = card.get('AusweisId')
if dec_name:
class_map[dec_name.strip().lower()] = dec_cls
if raw_name:
class_map[raw_name] = dec_cls
if ausweis_id:
class_map[str(ausweis_id).strip().lower()] = dec_cls
# Datenaufbereitung für das Template
for record in overdue_records:
item_id = str(record.get('Item') or '')
item_doc = item_map.get(item_id, {})
item_name = item_doc.get('Name', item_id)
item_code = item_doc.get('Code_4', '')
if item_code:
item_name = f"{item_name} ({item_code})"
raw_user = record.get('User', '')
decrypted_user = safe_decrypt(raw_user)
display_user = decrypted_user if decrypted_user else (raw_user or 'Unbekannt')
user_class = (
safe_decrypt(record.get('Klasse'))
or safe_decrypt(record.get('Class'))
or safe_decrypt(record.get('school_class'))
or class_map.get(raw_user, '')
or class_map.get(decrypted_user, '')
or class_map.get(display_user.strip().lower(), '')
or '—'
)
# KORREKTUR: 'record' statt 'appt'
due_date = record.get('DueDate', current_time)
due_date_naive = due_date.replace(tzinfo=None) if due_date else current_time_naive
days_overdue = (current_time_naive - due_date_naive).days
is_blocked = user_block_map.get(raw_user, False)
overdue_list.append({
'id': str(record.get('_id')),
'item_name': item_name,
'user': display_user,
'klasse': user_class,
'due_date': fmt_dt(due_date), # KORREKTUR: 'due_date' statt 'due_date_obj'
'days_overdue': days_overdue,
'mahnstufe': record.get('Mahnstufe', 0),
'is_blocked': is_blocked
})
return render_template(
'mahnungen_admin.html',
overdue_list=overdue_list,
library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
)
except Exception as e:
app.logger.error(f"Error loading mahnungen admin view: {e}")
flash('Fehler beim Laden der Mahnungsverwaltung.', 'error')
return redirect(url_for('home_admin'))
finally:
if client:
client.close()
""" test_overdue_list = [
{
"user": "Max Mustermann",
"klasse": "10A",
"item_name": "Biologie heute 2",
"due_date": "10.08.2026",
"days_overdue": 12,
"mahnstufe": 2,
"is_blocked": True
},
{
"user": "Anna Schmidt",
"klasse": "9B",
"item_name": "Taschenrechner TI-30",
"due_date": "15.08.2026",
"days_overdue": 7,
"mahnstufe": 1,
"is_blocked": False
},
{
"user": "Lukas Weber",
"klasse": "12",
"item_name": "Faust - Der Tragödie erster Teil",
"due_date": "21.08.2026",
"days_overdue": 1,
"mahnstufe": 0,
"is_blocked": False
}
]
# Render das Template und übergebe die Testdaten
return render_template('mahnungen_admin.html', overdue_list=test_overdue_list, APP_VERSION="1.0.0")"""
@app.route('/mahnungen_admin') @app.route('/mahnungen_admin')
def mahnungen_admin(): def mahnungen_admin():
"""Admin overview for overdue library items (Mahnungen).""" """Admin-Übersicht für überfällige Bibliotheks-Ausleihen."""
if 'username' not in session: if 'username' not in session:
flash( flash('Bitte melden Sie sich an.', 'error')
'Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!',
'error')
return redirect(url_for('login')) return redirect(url_for('login'))
current_permissions = us.get_effective_permissions(session['username']) current_permissions = us.get_effective_permissions(session['username'])
# Hier nutzen wir beispielhaft die library_loans_admin Berechtigung
if not current_permissions['pages'].get('library_loans_admin', False): if not current_permissions['pages'].get('library_loans_admin', False):
flash( flash('Fehlende Berechtigung.', 'error')
'Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!',
'error')
return redirect(url_for('library_view')) return redirect(url_for('library_view'))
if not cfg.MODULES.is_enabled('library'): if not cfg.MODULES.is_enabled('library'):
@@ -3817,14 +3648,6 @@ def mahnungen_admin():
except Exception: except Exception:
return str(dt) if dt else 'Unbekannt' return str(dt) if dt else 'Unbekannt'
def safe_decrypt(val):
if not val or not isinstance(val, str):
return val or ''
try:
return decrypt_text(val)
except Exception:
return val
current_time = datetime.datetime.now(ZoneInfo("Europe/Berlin")) current_time = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
current_time_naive = current_time.replace(tzinfo=None) current_time_naive = current_time.replace(tzinfo=None)
client = None client = None
@@ -3834,7 +3657,6 @@ def mahnungen_admin():
db = client[MONGODB_DB] db = client[MONGODB_DB]
ausleihungen_col = db['ausleihungen'] ausleihungen_col = db['ausleihungen']
items_col = db['items'] items_col = db['items']
users_col = db['users']
student_cards_col = db['student_cards'] student_cards_col = db['student_cards']
overdue_records = list(ausleihungen_col.find({ overdue_records = list(ausleihungen_col.find({
@@ -3845,6 +3667,7 @@ def mahnungen_admin():
overdue_list = [] overdue_list = []
if overdue_records: if overdue_records:
# 1. Items laden
item_ids = [] item_ids = []
for r in overdue_records: for r in overdue_records:
i_id = r.get('Item') i_id = r.get('Item')
@@ -3854,72 +3677,51 @@ def mahnungen_admin():
except Exception: except Exception:
item_ids.append(str(i_id)) item_ids.append(str(i_id))
items_cursor = items_col.find({ items_cursor = items_col.find({'_id': {'$in': item_ids}}, {'Name': 1, 'Code_4': 1})
'_id': {'$in': item_ids}
}, {'Name': 1, 'Code_4': 1})
item_map = {str(item['_id']): item for item in items_cursor} item_map = {str(item['_id']): item for item in items_cursor}
raw_users = [r.get('User') for r in overdue_records if r.get('User')] # 2. Alle Schülerausweise entschlüsseln und mappen
users_cursor = users_col.find({'username': {'$in': raw_users}}, {'username': 1, 'is_blocked': 1}) all_cards = list(student_cards_col.find())
user_block_map = {u.get('username'): u.get('is_blocked', False) for u in users_cursor} card_map = {}
for c in all_cards:
class_map = {} dec_card = _decrypt_student_card_doc(c) if '_decrypt_student_card_doc' in globals() else c
all_cards = list(student_cards_col.find({}, {'SchülerName': 1, 'Klasse': 1, 'AusweisId': 1})) ausweis_id = dec_card.get('AusweisId')
for card in all_cards:
raw_cls = card.get('Klasse')
dec_cls = safe_decrypt(raw_cls)
if not dec_cls:
continue
raw_name = card.get('SchülerName')
dec_name = safe_decrypt(raw_name)
ausweis_id = card.get('AusweisId')
if dec_name:
class_map[dec_name.strip().lower()] = dec_cls
if raw_name:
class_map[raw_name] = dec_cls
if ausweis_id: if ausweis_id:
class_map[str(ausweis_id).strip().lower()] = dec_cls card_map[str(ausweis_id).strip().upper()] = dec_card
# 3. Liste für das Frontend aufbauen
for record in overdue_records: for record in overdue_records:
item_id = str(record.get('Item') or '') item_id = str(record.get('Item') or '')
item_doc = item_map.get(item_id, {}) item_doc = item_map.get(item_id, {})
item_name = item_doc.get('Name', item_id) item_name = item_doc.get('Name', item_id)
item_code = item_doc.get('Code_4', '') item_code = item_doc.get('Code_4', '')
if item_code: if item_code:
item_name = f"{item_name} ({item_code})" item_name = f"{item_name} ({item_code})"
raw_user = record.get('User', '') raw_user = str(record.get('User') or '')
decrypted_user = safe_decrypt(raw_user) decrypted_user = decrypt_text(raw_user)
display_user = decrypted_user if decrypted_user else (raw_user or 'Unbekannt') ausweis_id = (decrypted_user if decrypted_user else raw_user).strip().upper()
user_class = ( student_card = card_map.get(ausweis_id, {})
safe_decrypt(record.get('Klasse')) student_name = student_card.get('SchülerName', ausweis_id if ausweis_id else 'Unbekannt')
or safe_decrypt(record.get('Class')) student_class = student_card.get('Klasse', '—')
or safe_decrypt(record.get('school_class')) student_email = student_card.get('email') or student_card.get('Email', '')
or class_map.get(raw_user, '') is_blocked = student_card.get('is_blocked', False)
or class_map.get(decrypted_user, '')
or class_map.get(display_user.strip().lower(), '')
or '—'
)
due_date_obj = record.get('DueDate') due_date_obj = record.get('DueDate')
if due_date_obj: if due_date_obj:
due_date_naive = due_date_obj.replace(tzinfo=None) if due_date_obj.tzinfo else due_date_obj due_date_naive = due_date_obj.replace(tzinfo=None) if due_date_obj.tzinfo else due_date_obj
days_overdue = (current_time_naive - due_date_naive).days days_overdue = (current_time_naive - due_date_naive).days
else: else:
days_overdue = 0 days_overdue = 0
is_blocked = user_block_map.get(raw_user, False)
overdue_list.append({ overdue_list.append({
'id': str(record.get('_id')), 'id': str(record.get('_id')),
'item_name': item_name, 'item_name': item_name,
'user': display_user, 'ausweis_id': ausweis_id,
'klasse': user_class, 'schueler_name': student_name,
'klasse': student_class,
'email': student_email,
'due_date': fmt_dt(due_date_obj), 'due_date': fmt_dt(due_date_obj),
'days_overdue': days_overdue, 'days_overdue': days_overdue,
'mahnstufe': record.get('Mahnstufe', 0), 'mahnstufe': record.get('Mahnstufe', 0),
@@ -3934,13 +3736,156 @@ def mahnungen_admin():
) )
except Exception as e: except Exception as e:
app.logger.error(f"Error loading mahnungen admin view: {e}") app.logger.error(f"Fehler beim Laden der Mahnungsverwaltung: {e}")
flash('Fehler beim Laden der Mahnungsverwaltung.', 'error') flash('Fehler beim Laden der Mahnungsverwaltung.', 'error')
return redirect(url_for('home_admin')) return redirect(url_for('home_admin'))
finally: finally:
if client: if client:
client.close() client.close()
@app.route('/mahnungen_reset', methods=['POST'])
def mahnungen_reset():
"""Setzt die Mahnstufe einer Ausleihe zurück, verlängert die Frist und entsperrt den Schülerausweis."""
if 'username' not in session:
return jsonify({'success': False, 'message': 'Nicht angemeldet.'}), 401
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['pages'].get('library_loans_admin', False):
return jsonify({'success': False, 'message': 'Keine Berechtigung.'}), 403
data = request.get_json() or {}
loan_id = data.get('loan_id')
if not loan_id:
return jsonify({'success': False, 'message': 'Ausleih-ID fehlt.'}), 400
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
ausleihungen_col = db['ausleihungen']
student_cards_col = db['student_cards']
# 1. Ausleihe finden
record = ausleihungen_col.find_one({'_id': ObjectId(loan_id)})
if not record:
return jsonify({'success': False, 'message': 'Ausleihe nicht gefunden.'}), 404
current_time = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
new_due_date = current_time + datetime.timedelta(days=14) # Frist um 14 Tage verlängern
# 2. Mahnstufe zurücksetzen und Frist verlängern
ausleihungen_col.update_one(
{'_id': ObjectId(loan_id)},
{'$set': {
'Mahnstufe': 0,
'DueDate': new_due_date,
'LastUpdated': current_time
}}
)
# 3. Schülerausweis ermitteln und entsperren
raw_user = str(record.get('User', ''))
decrypted_user = decrypt_text(raw_user)
ausweis_id = decrypted_user if decrypted_user else raw_user
card = student_cards_col.find_one({'AusweisId': ausweis_id})
if not card and raw_user != ausweis_id:
card = student_cards_col.find_one({'AusweisId': raw_user})
if card:
student_cards_col.update_one(
{'_id': card['_id']},
{'$set': {
'is_blocked': False,
'block_reason': '',
'Aktualisiert': current_time
}}
)
app.logger.info(f"Schülerausweis '{ausweis_id}' manuell entsperrt und Mahnstufe zurückgesetzt.")
return jsonify({
'success': True,
'message': 'Mahnstufe zurückgesetzt, Frist um 14 Tage verlängert und Ausweis entsperrt.'
})
except Exception as e:
app.logger.error(f"Fehler beim Zurücksetzen der Mahnung: {e}")
return jsonify({'success': False, 'message': f'Fehler beim Zurücksetzen: {str(e)}'}), 500
finally:
if client:
client.close()
@app.route('/mahnungen_send_email', methods=['POST'])
def mahnungen_send_email():
"""Verschickt manuell eine Mahnungs-E-Mail über die Eingabe aus dem Frontend-Modal."""
if 'username' not in session:
return jsonify({'success': False, 'message': 'Nicht angemeldet.'}), 401
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['pages'].get('library_loans_admin', False):
return jsonify({'success': False, 'message': 'Keine Berechtigung.'}), 403
data = request.get_json() or {}
loan_id = data.get('loan_id')
recipient_email = data.get('email', '').strip()
if not loan_id or not recipient_email:
return jsonify({'success': False, 'message': 'Ausleih-ID und E-Mail-Adresse sind erforderlich.'}), 400
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
ausleihungen_col = db['ausleihungen']
items_col = db['items']
student_cards_col = db['student_cards']
record = ausleihungen_col.find_one({'_id': ObjectId(loan_id)})
if not record:
return jsonify({'success': False, 'message': 'Ausleihe nicht gefunden.'}), 404
# Gegenstand und Schülerausweis ermitteln
raw_user = str(record.get('User', ''))
decrypted_user = decrypt_text(raw_user)
ausweis_id = decrypted_user if decrypted_user else raw_user
card = student_cards_col.find_one({'AusweisId': ausweis_id})
student_card = _decrypt_student_card_doc(card) if card and '_decrypt_student_card_doc' in globals() else (
card or {})
student_name = student_card.get('SchülerName', ausweis_id)
item_doc = items_col.find_one({'_id': ObjectId(str(record.get('Item')))}) if record.get('Item') else {}
item_name = item_doc.get('Name', 'Artikel') if item_doc else 'Artikel'
mahnstufe = record.get('Mahnstufe', 0)
# E-Mail Betreff & Text generieren
subject = f"{mahnstufe}. Mahnung: Bibliotheksausleihe überfällig" if mahnstufe > 0 else "Erinnerung: Bibliotheksausleihe"
note = (
f"Hallo {student_name},<br><br>"
f"dies ist eine Erinnerung bezüglich der Ausleihe für den Artikel <b>{item_name}</b>.<br>"
f"Aktuelle Mahnstufe: <b>{mahnstufe}</b>.<br><br>"
f"Bitte bringe den Artikel so schnell wie möglich in der Bibliothek zurück."
)
# E-Mail über existierende Mail-Funktion senden
send(email=recipient_email, subject=subject, note=note, sender="Bibliotheksverwaltung")
# Optional: E-Mail auch direkt auf der Schülerausweis-Karte hinterlegen/aktualisieren
if card:
student_cards_col.update_one({'_id': card['_id']}, {'$set': {'email': recipient_email}})
return jsonify({'success': True, 'message': f'Mahnung erfolgreich an {recipient_email} gesendet.'})
except Exception as e:
app.logger.error(f"Fehler beim Senden der Mahn-E-Mail: {e}")
return jsonify({'success': False, 'message': f'Fehler beim Senden: {str(e)}'}), 500
finally:
if client:
client.close()
@app.route('/api/library_items') @app.route('/api/library_items')
def api_library_items(): def api_library_items():
""" """
@@ -4181,11 +4126,13 @@ def api_library_group(series_group_id):
app.logger.error('Error loading library group %s: %s', series_group_id, exc) app.logger.error('Error loading library group %s: %s', series_group_id, exc)
return jsonify({'items': [], 'message': 'Gruppe konnte nicht geladen werden.'}), 500 return jsonify({'items': [], 'message': 'Gruppe konnte nicht geladen werden.'}), 500
@app.route('/api/library_return_by_code', methods=['POST']) @app.route('/api/library_return_by_code', methods=['POST'])
def api_library_return_by_code(): def api_library_return_by_code():
""" """
Return a library item by scanning its code only (no student card required). Return a library item by scanning its code only (no student card required).
This marks active ausleihungen for the item as completed and updates item status. This marks active ausleihungen for the item as completed and updates item status.
Automatically unblocks the student card if no other overdue items exist.
""" """
if 'username' not in session: if 'username' not in session:
return jsonify({'ok': False, 'message': 'Nicht angemeldet.'}), 401 return jsonify({'ok': False, 'message': 'Nicht angemeldet.'}), 401
@@ -4206,6 +4153,7 @@ def api_library_return_by_code():
db = client[MONGODB_DB] db = client[MONGODB_DB]
items_col = db['items'] items_col = db['items']
ausleihungen_col = db['ausleihungen'] ausleihungen_col = db['ausleihungen']
student_cards_col = db['student_cards'] # Hinzugefügt für die Entsperr-Logik
query_or = [ query_or = [
{'Code_4': item_code_raw}, {'Code_4': item_code_raw},
@@ -4229,7 +4177,10 @@ def api_library_return_by_code():
if item_doc.get('Verfuegbar', True): if item_doc.get('Verfuegbar', True):
return jsonify({'ok': False, 'message': 'Dieses Medium ist nicht als ausgeliehen markiert.'}), 409 return jsonify({'ok': False, 'message': 'Dieses Medium ist nicht als ausgeliehen markiert.'}), 409
# Mark active ausleihungen as completed # 1. Aktive Ausleihungen VOR dem Update zwischenspeichern, um den Nutzer (AusweisId) zu identifizieren
active_loans = list(ausleihungen_col.find({'Item': item_id, 'Status': 'active'}))
# 2. Mark active ausleihungen as completed
update_result = ausleihungen_col.update_many( update_result = ausleihungen_col.update_many(
{'Item': item_id, 'Status': 'active'}, {'Item': item_id, 'Status': 'active'},
{'$set': { {'$set': {
@@ -4239,10 +4190,46 @@ def api_library_return_by_code():
}} }}
) )
# Update item status to available # 3. Update item status to available
borrower_name = str(item_doc.get('User') or '').strip() or '' borrower_name = str(item_doc.get('User') or '').strip() or ''
it.update_item_status(item_id, True, borrower_name) it.update_item_status(item_id, True, borrower_name)
# 4. Automatische Entsperr-Logik für die identifizierten Schülerausweise
for loan in active_loans:
raw_user = str(loan.get('User', ''))
if not raw_user:
continue
# AusweisId entschlüsseln
decrypted_user = decrypt_text(raw_user) if 'safe_decrypt' in globals() else raw_user
ausweis_id = decrypted_user if decrypted_user else raw_user
# Prüfen, ob der Schüler noch andere eskalierte Ausleihungen hat
# Da die aktuelle Ausleihe oben auf 'completed' gesetzt wurde, wird sie hier nicht mitgezählt!
weitere_sperren = ausleihungen_col.count_documents({
'User': {'$in': [raw_user, encrypt_text(ausweis_id) if 'encrypt_text' in globals() else ausweis_id]},
'Status': 'active',
'Mahnstufe': {'$gte': 2}
})
# Wenn keine anderen Mahnstufe 2+ Ausleihungen aktiv sind -> Ausweis entsperren
if weitere_sperren == 0:
card = student_cards_col.find_one({'AusweisId': ausweis_id})
if not card and raw_user != ausweis_id:
card = student_cards_col.find_one({'AusweisId': raw_user})
if card and card.get('is_blocked'):
student_cards_col.update_one(
{'_id': card['_id']},
{'$set': {
'is_blocked': False,
'block_reason': '',
'Aktualisiert': now
}}
)
app.logger.info(
f"Automatische Entsperrung: Schülerausweis '{ausweis_id}' wurde nach Code-Rückgabe entsperrt.")
_append_audit_event_standalone( _append_audit_event_standalone(
event_type='ausleihung_returned_by_code', event_type='ausleihung_returned_by_code',
payload={ payload={
+145 -60
View File
@@ -129,97 +129,182 @@
} }
</style> </style>
<div class="mahnungen-shell"> <div class="container-fluid py-4">
<div class="mahnungen-head"> <div class="row mb-4">
<h1>Mahnungsübersicht</h1> <div class="col-12">
<div class="head-actions"> <div class="card shadow-sm">
<a class="btn btn-outline-secondary" href="{{ url_for('library_loans_admin') }}">Zur Ausleihenverwaltung</a> <div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<a class="btn btn-secondary" href="{{ url_for('library_view') }}">Bibliothek öffnen</a> <h5 class="mb-0"><i class="bi bi-exclamation-triangle-fill me-2"></i>Mahnungs- und Ausleihverwaltung</h5>
<span class="badge bg-light text-dark">Übersicht überfälliger Medien</span>
</div> </div>
</div> <div class="card-body">
{% if overdue_items and overdue_items|length > 0 %}
<div class="mahnungen-card"> <div class="table-responsive">
{% if overdue_list %} <table class="table table-hover align-middle">
<div class="search-box-wrapper"> <thead class="table-light">
<input type="text" id="searchInput" class="search-box" placeholder="🔍 Suche nach Nutzer, Klasse oder Gegenstand..." onkeyup="filterTable()">
</div>
<table class="mahnungen-table" id="mahnungTable">
<thead>
<tr> <tr>
<th>Nutzer</th> <th>Schüler / Ausweis</th>
<th>Klasse</th> <th>Klasse</th>
<th>Gegenstand</th> <th>Medium</th>
<th>Fällig am</th> <th>Fälligkeitsdatum</th>
<th>Tage drüber</th> <th>Überfällig seit</th>
<th>Mahnstufe</th> <th>Mahnstufe</th>
<th>Konto-Status</th> <th class="text-end">Aktionen</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for item in overdue_list %} {% for item in overdue_items %}
<tr> <tr>
<td><strong>{{ item.user or '—' }}</strong></td>
<td>{{ item.klasse or '—' }}</td>
<td>{{ item.item_name or '—' }}</td>
<td><span class="mono">{{ item.due_date or '—' }}</span></td>
<td class="text-danger">{{ item.days_overdue }} Tage</td>
<td> <td>
{% if item.mahnstufe == 2 %} <strong>{{ item.schueler_name }}</strong><br>
<span class="badge-pill badge-open">Stufe 2 (Gesperrt)</span> <small class="text-muted">{{ item.ausweis_id }}</small>
{% elif item.mahnstufe == 1 %} </td>
<span class="badge-pill badge-warning">Stufe 1 (Gewarnt)</span> <td>{{ item.klasse }}</td>
{% else %} <td>
<span class="badge-pill badge-info">Stufe 0 (Neu)</span> {{ item.item_name }}<br>
{% endif %} <small class="text-muted">Code: {{ item.item_code or '—' }}</small>
</td>
<td>{{ item.due_date }}</td>
<td>
<span class="badge bg-danger">{{ item.days_overdue }} Tage</span>
</td> </td>
<td> <td>
{% if item.is_blocked %} {% if item.mahnstufe == 2 %}
<span class="badge-pill badge-open">Gesperrt</span> <span class="badge bg-dark text-danger">Stufe 2 (Gesperrt)</span>
{% elif item.mahnstufe == 1 %}
<span class="badge bg-warning text-dark">Stufe 1</span>
{% else %} {% else %}
<span class="badge-pill badge-paid">Aktiv</span> <span class="badge bg-secondary">Stufe 0</span>
{% endif %} {% endif %}
</td> </td>
<td class="text-end">
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-outline-primary"
onclick="openEmailModal('{{ item.id }}', '{{ item.schueler_name }}', '{{ item.email }}')">
<i class="bi bi-envelope"></i> E-Mail
</button>
<button type="button" class="btn btn-sm btn-outline-warning text-dark"
onclick="resetMahnung('{{ item.id }}', '{{ item.schueler_name }}')">
<i class="bi bi-arrow-counterclockwise"></i> Zurücksetzen
</button>
</div>
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
{% else %} {% else %}
<div class="empty-state"> <div class="text-center py-5">
<strong>Hervorragend!</strong> Aktuell gibt es keine überfälligen Ausleihen im System. <i class="bi bi-check-circle-fill text-success fs-1"></i>
<p class="text-muted mt-2">Aktuell gibt es keine überfälligen Mahnungen.</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div>
</div>
</div>
</div>
<!-- E-Mail Modal -->
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="emailModalLabel">Mahnungs-E-Mail senden</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Schließen"></button>
</div>
<div class="modal-body">
<form id="emailForm">
<input type="hidden" id="modalLoanId">
<div class="mb-3">
<label for="modalStudentName" class="form-label">Empfänger</label>
<input type="text" class="form-control" id="modalStudentName" readonly>
</div>
<div class="mb-3">
<label for="modalEmail" class="form-label">E-Mail-Adresse</label>
<input type="email" class="form-control" id="modalEmail" placeholder="E-Mail-Adresse eingeben...">
</div>
<div class="mb-3">
<label for="modalMessage" class="form-label">Nachricht</label>
<textarea class="form-control" id="modalMessage" rows="4" placeholder="Optionaler Text..."></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="button" class="btn btn-primary" onclick="submitEmailMahnung()">E-Mail senden</button>
</div>
</div>
</div>
</div> </div>
<script> <script>
function filterTable() { function openEmailModal(loanId, studentName, studentEmail) {
const input = document.getElementById("searchInput"); document.getElementById('modalLoanId').value = loanId;
const filter = input.value.toLowerCase(); document.getElementById('modalStudentName').value = studentName;
const table = document.getElementById("mahnungTable"); document.getElementById('modalEmail').value = studentEmail || '';
const tr = table.getElementsByTagName("tbody")[0].getElementsByTagName("tr"); document.getElementById('modalMessage').value = '';
for (let i = 0; i < tr.length; i++) { var myModal = new bootstrap.Modal(document.getElementById('emailModal'));
const tdUser = tr[i].getElementsByTagName("td")[0]; myModal.show();
const tdClass = tr[i].getElementsByTagName("td")[1]; }
const tdItem = tr[i].getElementsByTagName("td")[2];
if (tdUser || tdClass || tdItem) { function submitEmailMahnung() {
const textUser = tdUser.textContent || tdUser.innerText; const loanId = document.getElementById('modalLoanId').value;
const textClass = tdClass.textContent || tdClass.innerText; const email = document.getElementById('modalEmail').value;
const textItem = tdItem.textContent || tdItem.innerText; const message = document.getElementById('modalMessage').value;
if ( if (!email) {
textUser.toLowerCase().indexOf(filter) > -1 || alert('Bitte geben Sie eine gültige E-Mail-Adresse ein.');
textClass.toLowerCase().indexOf(filter) > -1 || return;
textItem.toLowerCase().indexOf(filter) > -1 }
) {
tr[i].style.display = ""; fetch('/mahnungen_send_email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ loan_id: loanId, email: email, message: message })
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('E-Mail erfolgreich gesendet!');
var modalEl = document.getElementById('emailModal');
var modal = bootstrap.Modal.getInstance(modalEl);
modal.hide();
} else { } else {
tr[i].style.display = "none"; alert('Fehler: ' + data.message);
}
} }
})
.catch(error => {
alert('Fehler beim Senden der Anfrage.');
console.error(error);
});
}
function resetMahnung(loanId, studentName) {
if (!confirm('Möchten Sie die Mahnung für "' + studentName + '" wirklich zurücksetzen?\n\nDadurch wird die Mahnstufe auf 0 gesetzt, die Frist um 14 Tage verlängert und der Schülerausweis entsperrt.')) {
return;
} }
fetch('/mahnungen_reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ loan_id: loanId })
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(data.message);
location.reload(); // Seite neu laden, um die aktualisierte Ansicht zu sehen
} else {
alert('Fehler: ' + data.message);
} }
})
.catch(error => {
alert('Fehler beim Senden der Anfrage.');
console.error(error);
});
}
</script> </script>
{% endblock %} {% endblock %}