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
+330 -343
View File
@@ -1339,14 +1339,18 @@ def update_appointment_statuses():
Diese Funktion wird jede Minute ausgeführt und überprüft:
- Geplante Termine, die aktiviert werden sollten
- Aktive Termine, die beendet werden sollten
- Überfällige Bibliotheksartikel (Mahnlauf & Admin-Benachrichtigungen)
"""
current_time = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
current_time_naive = current_time.replace(tzinfo=None)
client = None
try:
# Hole alle Termine mit Status 'planned' oder 'active'
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
ausleihungen = db['ausleihungen']
items_col = db['items']
student_cards_col = db['student_cards']
# Finde alle Termine, die status updates benötigen
appointments_to_check = list(ausleihungen.find({
@@ -1370,9 +1374,7 @@ def update_appointment_statuses():
extra_fields = {}
# --- Conflict resolver: planned → active transition ---
# Check if the physical item is already borrowed by someone else
if old_status == 'planned' and new_status == 'active':
items_col = db['items']
item_id_str = appointment.get('Item')
conflict_detected = False
conflict_note = ''
@@ -1387,7 +1389,6 @@ def update_appointment_statuses():
item_name = item_doc.get('Name', item_id_str)
activation_item_name = item_name
total_exemplare = int(item_doc.get('Exemplare', 1))
# Count how many active (non-planned) borrows currently hold this item
active_borrows = ausleihungen.count_documents({
'Item': item_id_str,
'Status': 'active',
@@ -1396,7 +1397,6 @@ def update_appointment_statuses():
if active_borrows >= total_exemplare or item_doc.get('Verfuegbar') is False:
conflict_detected = True
borrower = item_doc.get('User', 'unbekannter Benutzer')
item_name = item_doc.get('Name', item_id_str)
conflict_note = (
f"Gegenstand '{item_name}' war beim Aktivieren von "
f"'{appointment.get('User', '?')}' bereits ausgeliehen "
@@ -1405,13 +1405,10 @@ def update_appointment_statuses():
extra_fields['ConflictDetected'] = True
extra_fields['ConflictNote'] = conflict_note
extra_fields['ConflictAt'] = current_time
conflict_log = (
f" [KONFLIKT] Termin {appointment['_id']}: "
f"planned → active, aber {conflict_note}"
app.logger.warning(
f" [KONFLIKT] Termin {appointment['_id']}: planned → active, aber {conflict_note}"
)
app.logger.warning(conflict_log)
else:
# No conflict — clear any previously stored conflict flag
extra_fields['ConflictDetected'] = False
extra_fields['ConflictNote'] = ''
except Exception as conflict_err:
@@ -1432,7 +1429,6 @@ def update_appointment_statuses():
updated_count += 1
if new_status == 'active':
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):
try:
it.update_item_status(str(appointment.get('Item')), False, activation_user)
@@ -1441,14 +1437,12 @@ def update_appointment_statuses():
elif new_status == 'completed':
completed_count += 1
# Make item available again
if appointment.get('Item'):
try:
it.update_item_status(str(appointment.get('Item')), True)
except Exception as 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:
try:
_create_notification(
@@ -1456,9 +1450,7 @@ def update_appointment_statuses():
audience='user',
notif_type='appointment_activated',
title='Reservierung ist jetzt aktiv',
message=(
f"Deine geplante Ausleihe für {activation_item_name} startet jetzt."
),
message=f"Deine geplante Ausleihe für {activation_item_name} startet jetzt.",
target_user=activation_user,
reference={
'appointment_id': str(appointment.get('_id')),
@@ -1472,108 +1464,131 @@ def update_appointment_statuses():
app.logger.warning(
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')):
current_time = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
appt = appointment # Verwende das aktuelle Dokument aus der Schleife
if appt.get('Status') != 'active':
continue
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
ausleihungen = db['ausleihungen']
users_col = db['users']
items_col = db['items']
due_date_obj = appt.get('DueDate')
if not due_date_obj:
continue
# Finde alle aktiven Ausleihungen, die in der Vergangenheit fällig waren
overdue_appointments = list(ausleihungen.find({
'Status': 'active',
'DueDate': {'$lt': current_time}
}))
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
for appt in overdue_appointments:
days_overdue = (current_time - appt.get('DueDate', current_time)).days
mahnstufe = appt.get('Mahnstufe', 0)
target_user = str(appt.get('User', '')).strip()
item_id = appt.get('Item')
mahnstufe = appt.get('Mahnstufe', 0)
raw_user = str(appt.get('User', '')).strip()
# 1. Objektdetails holen (für den E-Mail-Text)
item_name = "Unbekannter Artikel"
if item_id:
try:
item_doc = items_col.find_one({'_id': ObjectId(item_id)})
if item_doc:
item_name = item_doc.get('Name', str(item_id))
except Exception:
pass
# 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')
# 2. Nutzerdetails holen (für die E-Mail-Adresse)
user_doc = users_col.find_one({'username': target_user}) # Feldnamen ggf. anpassen (z.B. '_id')
if not user_doc:
app.logger.warning(f"Nutzer '{target_user}' für Mahnung nicht gefunden.")
continue
if not target_ausweis_id:
continue
user_email = user_doc.get('email')
# 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})
# 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}}
)
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"
if item_id:
try:
item_doc = items_col.find_one({'_id': ObjectId(str(item_id))})
if item_doc:
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:
pass
# STUFE 2: >= 28 Tage überfällig -> Ausweis sperren, Stufe 2 setzen & Admins benachrichtigen
if days_overdue >= 28 and mahnstufe < 2:
ausleihungen.update_one(
{'_id': appt['_id']},
{'$set': {'Mahnstufe': 2, 'LastUpdated': current_time}}
)
block_reason = f'System-Sperre: Ausleihe von "{item_name}" ist seit {days_overdue} Tagen überfällig.'
student_cards_col.update_one(
{'_id': card['_id']},
{'$set': {
'is_blocked': True,
'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(
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'
db, audience='admin', notif_type='error',
title=title, message=body, severity='critical',
unique_key=f"mahnlauf:st2:{appt['_id']}"
)
except Exception as n_err:
app.logger.warning(f"Fehler beim Erstellen der Admin-Notif (Stufe 2): {n_err}")
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.")
# 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}")
elif days_overdue >= 28 and mahnstufe == 1:
# STUFE 2: Letzte Mahnung, Sperrung und Report
ausleihungen.update_one(
{'_id': appt['_id']},
{'$set': {'Mahnstufe': 2, 'LastUpdated': current_time}}
)
app.logger.warning(f"Mahnstufe 2 & Ausweis-Sperre für Schülerausweis '{student_name}' ({target_ausweis_id}) gesetzt.")
# Nutzerkonto für weitere Ausleihen sperren
users_col.update_one(
{'_id': user_doc['_id']},
{'$set': {
'is_blocked': True,
'block_reason': f'System-Sperre: Ausleihe von "{item_name}" {days_overdue} Tage überfällig.'
}}
)
# 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}}
)
title = '1. Mahnung erreicht'
body = f'Ausleihe überfällig: {student_name} (Klasse {student_class}) hat "{item_name}" seit {days_overdue} Tagen nicht zurückgegeben.'
target_url = '/mahnungen_admin'
# 1. In-App Notification (Admin-Tab)
if '_create_notification' in globals():
try:
_create_notification(
db, audience='user', notif_type='error',
title='Konto gesperrt - 2. Mahnung',
message=f'Aufgrund der starken Überfälligkeit von "{item_name}" wurde dein Konto vorübergehend gesperrt.',
target_user=target_user, severity='critical'
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}")
if user_email:
subject = "WICHTIG: Kontosperrung & 2. Mahnung"
note = (f"Hallo {target_user},<br><br>"
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.")
# 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 1): {p_err}")
client.close()
except Exception as e:
app.logger.error(f"Fehler bei der automatischen Mahnlauf-Prüfung: {e}")
client.close()
app.logger.info(f"Mahnstufe 1 für Schülerausweis '{student_name}' ({target_ausweis_id}) gesetzt.")
if updated_count > 0:
app.logger.warning(
@@ -1582,6 +1597,9 @@ def update_appointment_statuses():
except Exception as e:
app.logger.error(f"Automatic appointment status update failed: {e}")
finally:
if client:
client.close()
# Initialize scheduler instances
@@ -3606,205 +3624,18 @@ def test_mahnungen():
# Testdaten (Mock-Daten), um alle if/else Bedingungen im HTML zu testen
generate_test_ausleihen()
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')
def mahnungen_admin():
"""Admin overview for overdue library items (Mahnungen)."""
"""Admin-Übersicht für überfällige Bibliotheks-Ausleihen."""
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')
flash('Bitte melden Sie sich an.', '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')
flash('Fehlende Berechtigung.', 'error')
return redirect(url_for('library_view'))
if not cfg.MODULES.is_enabled('library'):
@@ -3817,14 +3648,6 @@ def mahnungen_admin():
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
@@ -3834,7 +3657,6 @@ def mahnungen_admin():
db = client[MONGODB_DB]
ausleihungen_col = db['ausleihungen']
items_col = db['items']
users_col = db['users']
student_cards_col = db['student_cards']
overdue_records = list(ausleihungen_col.find({
@@ -3845,6 +3667,7 @@ def mahnungen_admin():
overdue_list = []
if overdue_records:
# 1. Items laden
item_ids = []
for r in overdue_records:
i_id = r.get('Item')
@@ -3854,72 +3677,51 @@ def mahnungen_admin():
except Exception:
item_ids.append(str(i_id))
items_cursor = items_col.find({
'_id': {'$in': item_ids}
}, {'Name': 1, 'Code_4': 1})
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
# 2. Alle Schülerausweise entschlüsseln und mappen
all_cards = list(student_cards_col.find())
card_map = {}
for c in all_cards:
dec_card = _decrypt_student_card_doc(c) if '_decrypt_student_card_doc' in globals() else c
ausweis_id = dec_card.get('AusweisId')
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:
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')
raw_user = str(record.get('User') or '')
decrypted_user = decrypt_text(raw_user)
ausweis_id = (decrypted_user if decrypted_user else raw_user).strip().upper()
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 ''
)
student_card = card_map.get(ausweis_id, {})
student_name = student_card.get('SchülerName', ausweis_id if ausweis_id else 'Unbekannt')
student_class = student_card.get('Klasse', '')
student_email = student_card.get('email') or student_card.get('Email', '')
is_blocked = student_card.get('is_blocked', False)
due_date_obj = record.get('DueDate')
if 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
else:
days_overdue = 0
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,
'ausweis_id': ausweis_id,
'schueler_name': student_name,
'klasse': student_class,
'email': student_email,
'due_date': fmt_dt(due_date_obj),
'days_overdue': days_overdue,
'mahnstufe': record.get('Mahnstufe', 0),
@@ -3934,13 +3736,156 @@ def mahnungen_admin():
)
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')
return redirect(url_for('home_admin'))
finally:
if client:
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')
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)
return jsonify({'items': [], 'message': 'Gruppe konnte nicht geladen werden.'}), 500
@app.route('/api/library_return_by_code', methods=['POST'])
def api_library_return_by_code():
"""
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.
Automatically unblocks the student card if no other overdue items exist.
"""
if 'username' not in session:
return jsonify({'ok': False, 'message': 'Nicht angemeldet.'}), 401
@@ -4206,6 +4153,7 @@ def api_library_return_by_code():
db = client[MONGODB_DB]
items_col = db['items']
ausleihungen_col = db['ausleihungen']
student_cards_col = db['student_cards'] # Hinzugefügt für die Entsperr-Logik
query_or = [
{'Code_4': item_code_raw},
@@ -4229,7 +4177,10 @@ def api_library_return_by_code():
if item_doc.get('Verfuegbar', True):
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(
{'Item': item_id, 'Status': 'active'},
{'$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 ''
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(
event_type='ausleihung_returned_by_code',
payload={
+168 -83
View File
@@ -129,97 +129,182 @@
}
</style>
<div class="mahnungen-shell">
<div class="mahnungen-head">
<h1>Mahnungsübersicht</h1>
<div class="head-actions">
<a class="btn btn-outline-secondary" href="{{ url_for('library_loans_admin') }}">Zur Ausleihenverwaltung</a>
<a class="btn btn-secondary" href="{{ url_for('library_view') }}">Bibliothek öffnen</a>
<div class="container-fluid py-4">
<div class="row mb-4">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<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 class="card-body">
{% if overdue_items and overdue_items|length > 0 %}
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th>Schüler / Ausweis</th>
<th>Klasse</th>
<th>Medium</th>
<th>Fälligkeitsdatum</th>
<th>Überfällig seit</th>
<th>Mahnstufe</th>
<th class="text-end">Aktionen</th>
</tr>
</thead>
<tbody>
{% for item in overdue_items %}
<tr>
<td>
<strong>{{ item.schueler_name }}</strong><br>
<small class="text-muted">{{ item.ausweis_id }}</small>
</td>
<td>{{ item.klasse }}</td>
<td>
{{ item.item_name }}<br>
<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>
{% if item.mahnstufe == 2 %}
<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 %}
<span class="badge bg-secondary">Stufe 0</span>
{% endif %}
</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>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<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>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="mahnungen-card">
{% if overdue_list %}
<div class="search-box-wrapper">
<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>
<th>Nutzer</th>
<th>Klasse</th>
<th>Gegenstand</th>
<th>Fällig am</th>
<th>Tage drüber</th>
<th>Mahnstufe</th>
<th>Konto-Status</th>
</tr>
</thead>
<tbody>
{% for item in overdue_list %}
<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>
{% if item.mahnstufe == 2 %}
<span class="badge-pill badge-open">Stufe 2 (Gesperrt)</span>
{% elif item.mahnstufe == 1 %}
<span class="badge-pill badge-warning">Stufe 1 (Gewarnt)</span>
{% else %}
<span class="badge-pill badge-info">Stufe 0 (Neu)</span>
{% endif %}
</td>
<td>
{% if item.is_blocked %}
<span class="badge-pill badge-open">Gesperrt</span>
{% else %}
<span class="badge-pill badge-paid">Aktiv</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<strong>Hervorragend!</strong> Aktuell gibt es keine überfälligen Ausleihen im System.
</div>
{% endif %}
</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>
<script>
function filterTable() {
const input = document.getElementById("searchInput");
const filter = input.value.toLowerCase();
const table = document.getElementById("mahnungTable");
const tr = table.getElementsByTagName("tbody")[0].getElementsByTagName("tr");
function openEmailModal(loanId, studentName, studentEmail) {
document.getElementById('modalLoanId').value = loanId;
document.getElementById('modalStudentName').value = studentName;
document.getElementById('modalEmail').value = studentEmail || '';
document.getElementById('modalMessage').value = '';
for (let i = 0; i < tr.length; i++) {
const tdUser = tr[i].getElementsByTagName("td")[0];
const tdClass = tr[i].getElementsByTagName("td")[1];
const tdItem = tr[i].getElementsByTagName("td")[2];
var myModal = new bootstrap.Modal(document.getElementById('emailModal'));
myModal.show();
}
if (tdUser || tdClass || tdItem) {
const textUser = tdUser.textContent || tdUser.innerText;
const textClass = tdClass.textContent || tdClass.innerText;
const textItem = tdItem.textContent || tdItem.innerText;
function submitEmailMahnung() {
const loanId = document.getElementById('modalLoanId').value;
const email = document.getElementById('modalEmail').value;
const message = document.getElementById('modalMessage').value;
if (
textUser.toLowerCase().indexOf(filter) > -1 ||
textClass.toLowerCase().indexOf(filter) > -1 ||
textItem.toLowerCase().indexOf(filter) > -1
) {
tr[i].style.display = "";
} else {
tr[i].style.display = "none";
}
}
if (!email) {
alert('Bitte geben Sie eine gültige E-Mail-Adresse ein.');
return;
}
}
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 {
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>
{% endblock %}