From dffcfdcba7a2b92126acd94447a1b372e60ce66c Mon Sep 17 00:00:00 2001 From: AIIrondev Date: Thu, 15 Oct 2026 21:33:41 +0200 Subject: [PATCH] Die blockierende Ausleihverarbeitung wurde korrigiert. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Änderungen Geplante Inventar-Ausleihen werden zum Startzeitpunkt automatisch aktiviert, wenn ein Exemplar verfügbar ist. Bei fehlender Verfügbarkeit bleibt die Reservierung geplant. Der reservierende Benutzer und der aktuelle Ausleiher erhalten jeweils eine Benachrichtigung. Die automatische Aktivierung wird beim nächsten Scheduler-Lauf erneut versucht, sobald der Gegenstand zurückgegeben wurde. Das geplante Ende beendet eine aktive Ausleihe nicht mehr automatisch. Nach Ablauf erscheint bei „Meine Ausleihungen“ ein deutlich roter Hinweis: RÜCKGABE ERFORDERLICH roter, hervorgehobener Button Rückgabe bestätigen Eine Ausleihe wird erst nach dem expliziten Bestätigen der Rückgabe auf completed gesetzt und wieder verfügbar gemacht. Rückgabeaktionen unterstützen nun auch Datensätze, bei denen die Item-ID als ObjectId gespeichert ist. Die Dokumentation des Ausleih-Workflows wurde aktualisiert. Geänderte Dateien: Web/app.py Web/modules/database/ausleihung.py Web/templates/my_borrowed_items.html Legal/TEST_AUSLEIHUNG.md Validiert mit: Python-Syntaxprüfung erfolgreich git diff --check erfolgreich Im Repository wurden keine ausführbaren Python-Testdateien gefunden --- Legal/TEST_AUSLEIHUNG.md | 10 +- Web/app.py | 182 +++++++++++++++++++++++---- Web/modules/database/ausleihung.py | 12 +- Web/templates/my_borrowed_items.html | 31 ++++- 4 files changed, 200 insertions(+), 35 deletions(-) diff --git a/Legal/TEST_AUSLEIHUNG.md b/Legal/TEST_AUSLEIHUNG.md index e81d57f..e9bfe94 100644 --- a/Legal/TEST_AUSLEIHUNG.md +++ b/Legal/TEST_AUSLEIHUNG.md @@ -20,13 +20,19 @@ pytest test_ausleihung.py -vv --tb=long ## Test Coverage -### ✅ Status Determination (5 tests) +### ✅ Status Determination - Future borrowings marked as 'planned' - Current borrowings marked as 'active' -- Past borrowings marked as 'completed' +- Active borrowings remain 'active' after the planned end until return confirmation - Cancelled status never changes - Active borrowings without end time +### ✅ Inventory borrowing workflow +- Planned inventory borrowings activate automatically at their start when the item is available +- Unavailable items remain planned and notify the requester and current holder +- Reaching the planned end creates a return warning instead of an automatic return +- The item is completed and made available only after the user confirms the return + ### ✅ Create Operations (2 tests) - Create immediately active borrowing - Create planned/future borrowing diff --git a/Web/app.py b/Web/app.py index aa08b0a..c26c30a 100755 --- a/Web/app.py +++ b/Web/app.py @@ -1382,6 +1382,10 @@ def update_appointment_statuses(): for appointment in appointments_to_check: old_status = appointment.get('Status') activation_user = str(appointment.get('User') or '').strip() + try: + activation_user = decrypt_text(activation_user) if activation_user else '' + except Exception: + pass activation_item_name = str(appointment.get('Item') or 'Termin') # is_library_item expects an item document, not the stored item id. @@ -1404,9 +1408,107 @@ def update_appointment_statuses(): ) item_is_library = it.is_library_item(item_doc_for_status) - # Aktuellen Status bestimmen + # Aktuellen Status bestimmen. Eine aktive Ausleihe bleibt bis zur + # bestätigten Rückgabe aktiv, auch wenn ihr Endzeitpunkt erreicht ist. new_status = au.get_current_status(appointment, log_changes=True, user='scheduler') + # Inventarreservierungen werden erst beim tatsächlichen Start aktiviert. + # Ist kein Exemplar frei, bleibt die Reservierung geplant und beide + # betroffenen Nutzer werden einmalig benachrichtigt. + if ( + old_status == 'planned' + and new_status == 'active' + and not item_is_library + and appointment.get('Item') + ): + item_id = appointment.get('Item') + item_doc = None + item_queries = [{'_id': item_id}] + try: + item_queries.insert(0, {'_id': ObjectId(str(item_id))}) + except (InvalidId, TypeError): + pass + item_doc = items_col.find_one({'$or': item_queries}) + + available = bool(item_doc and item_doc.get('Verfuegbar', True)) + if item_doc and int(item_doc.get('Exemplare', 1) or 1) > 1: + total_exemplare = int(item_doc.get('Exemplare', 1) or 1) + borrowed_exemplare = len(item_doc.get('ExemplareStatus') or []) + available = borrowed_exemplare < total_exemplare + + if item_doc: + active_query = { + 'Status': 'active', + '_id': {'$ne': appointment['_id']}, + '$or': [{'Item': item_id}, {'Item': str(item_id)}], + } + total_exemplare = int(item_doc.get('Exemplare', 1) or 1) + active_count = ausleihungen.count_documents(active_query) + available = available and active_count < total_exemplare + + if not available: + holder = '' + if item_doc and item_doc.get('User'): + try: + holder = decrypt_text(str(item_doc['User'])) + except Exception: + holder = str(item_doc['User']) + if not holder: + active_holder = ausleihungen.find_one( + { + 'Status': 'active', + '$or': [{'Item': item_id}, {'Item': str(item_id)}], + '_id': {'$ne': appointment['_id']}, + }, + {'User': 1}, + ) + if active_holder and active_holder.get('User'): + try: + holder = decrypt_text(str(active_holder['User'])) + except Exception: + holder = str(active_holder['User']) + requested_user = str(appointment.get('User') or '').strip() + try: + requested_user = decrypt_text(requested_user) if requested_user else '' + except Exception: + pass + item_name = (item_doc or {}).get('Name') or str(item_id) + conflict_fields = { + 'ConflictDetected': True, + 'ConflictNote': f'Keine Verfügbarkeit für "{item_name}" zum Startzeitpunkt.', + 'ConflictAt': current_time, + 'WaitingForAvailability': True, + } + ausleihungen.update_one( + {'_id': appointment['_id'], 'Status': 'planned'}, + {'$set': {**conflict_fields, 'LastUpdated': current_time}} + ) + if requested_user: + _create_notification( + db, + audience='user', + notif_type='borrowing_waiting_for_availability', + title='Ausleihe wartet auf Verfügbarkeit', + message=f'"{item_name}" ist zum geplanten Start noch ausgeliehen. Deine Reservierung bleibt aktiv und wird automatisch gestartet, sobald es zurückgegeben wurde.', + target_user=requested_user, + reference={'borrow_id': str(appointment['_id']), 'item_id': str(item_id)}, + unique_key=f'borrowing:waiting:{appointment["_id"]}:requester', + severity='warning', + ) + if holder and holder != requested_user: + _create_notification( + db, + audience='user', + notif_type='borrowing_blocking_return', + title='Rückgabe benötigt', + message=f'"{item_name}" wird für eine geplante Ausleihe benötigt. Bitte gib den Gegenstand zurück und bestätige die Rückgabe.', + target_user=holder, + reference={'borrow_id': str(appointment['_id']), 'item_id': str(item_id)}, + unique_key=f'borrowing:waiting:{appointment["_id"]}:holder', + severity='warning', + ) + continue + # Wenn sich der Status geändert hat, aktualisiere in der Datenbank if new_status != old_status and not item_is_library: extra_fields = {} @@ -1481,32 +1583,48 @@ def update_appointment_statuses(): except Exception as e: app.logger.warning(f"Could not update item status to True for {appointment['_id']}: {e}") - if old_status == 'planned' and new_status == 'active' and activation_user: - try: - _create_notification( - db, - audience='user', - notif_type='appointment_activated', - title='Reservierung ist jetzt aktiv', - message=f"Deine geplante Ausleihe für {activation_item_name} startet jetzt.", - target_user=activation_user, - reference={ - 'appointment_id': str(appointment.get('_id')), - 'item_id': str(appointment.get('Item') or ''), - 'event': 'activated', - }, - unique_key=f"appointment:activated:{appointment.get('_id')}", - severity='info', - ) - except Exception as notif_err: - app.logger.warning( - f"Failed to create activation notification for {appointment.get('_id')}: {notif_err}" - ) + # Endzeitpunkt erreicht: nur einen deutlich sichtbaren + # Rückgabehinweis setzen, niemals automatisch abschließen. + if ( + old_status == 'active' + and isinstance(appointment.get('End'), datetime.datetime) + and au.ensure_timezone_aware(appointment['End']) <= current_time + ): + ausleihungen.update_one( + {'_id': appointment['_id'], 'Status': 'active'}, + {'$set': { + 'ReturnPending': True, + 'ReturnReminderAt': current_time, + 'LastUpdated': current_time, + }} + ) + + if old_status == 'planned' and new_status == 'active' and activation_user: + try: + _create_notification( + db, + audience='user', + notif_type='appointment_activated', + title='Reservierung ist jetzt aktiv', + message=f"Deine geplante Ausleihe für {activation_item_name} startet jetzt.", + target_user=activation_user, + reference={ + 'appointment_id': str(appointment.get('_id')), + 'item_id': str(appointment.get('Item') or ''), + 'event': 'activated', + }, + unique_key=f"appointment:activated:{appointment.get('_id')}", + severity='info', + ) + except Exception as notif_err: + 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 item_is_library: + if item_is_library: appt = appointment # Verwende das aktuelle Dokument aus der Schleife if appt.get('Status') != 'active': continue @@ -7737,7 +7855,13 @@ def zurueckgeben(id): current_permissions = us.get_effective_permissions(session['username']) - if not item.get('Verfuegbar', True) and (current_permissions['actions'].get('can_manage_users', False)) or item.get('User') == username: + if ( + not item.get('Verfuegbar', True) + and ( + current_permissions['actions'].get('can_manage_users', False) + or item.get('User') == username + ) + ): try: # Get ALL active borrowing records for this item and complete them client = MongoClient(MONGODB_HOST, MONGODB_PORT) @@ -7746,7 +7870,7 @@ def zurueckgeben(id): # Find all active records for this item active_records = ausleihungen.find({ - 'Item': id, + '$or': [{'Item': id}, {'Item': ObjectId(id) if ObjectId.is_valid(id) else id}], 'Status': 'active' }) @@ -7762,6 +7886,7 @@ def zurueckgeben(id): {'$set': { 'Status': 'completed', 'End': end_date, + 'ReturnPending': False, 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin")) }} ) @@ -11437,14 +11562,21 @@ def my_borrowed_items(): 'notes': appointment.get('Notes'), 'period': appointment.get('Period'), 'status': appointment.get('VerifiedStatus', appointment.get('Status')), + 'return_pending': bool(appointment.get('ReturnPending')), } status = appointment.get('Status') if status == 'active': item_obj['ActiveAppointment'] = True + item_obj['ReturnPending'] = bool(appointment.get('ReturnPending')) if str(item_obj['_id']) not in processed_item_ids: active_items.append(item_obj) processed_item_ids.add(str(item_obj['_id'])) + elif item_obj['ReturnPending']: + for existing_item in active_items: + if str(existing_item.get('_id')) == str(item_obj['_id']): + existing_item['ReturnPending'] = True + break elif status == 'planned': planned_items.append(item_obj) diff --git a/Web/modules/database/ausleihung.py b/Web/modules/database/ausleihung.py index 725886c..1647c4a 100755 --- a/Web/modules/database/ausleihung.py +++ b/Web/modules/database/ausleihung.py @@ -15,7 +15,8 @@ Hauptfunktionen: Sammlungsstruktur: - ausleihungen: Speichert alle Ausleihungsdatensätze mit ihrem Status - - Status-Werte: 'planned' (geplant), 'active' (aktiv), 'completed' (abgeschlossen), 'cancelled' (storniert) + - Status-Werte: 'planned' (geplant), 'active' (aktiv), 'completed' (nach bestätigter Rückgabe), 'cancelled' (storniert) + - Das geplante Ende beendet eine aktive Ausleihe nicht automatisch; es erzeugt einen Rückgabehinweis. """ ''' Copyright 2025-2026 AIIrondev @@ -96,13 +97,10 @@ def get_current_status(ausleihung, log_changes=False, user=None): ausleihung_id = str(ausleihung.get('_id', 'unknown'))[:12] print(f"[DEBUG] Ausleihe {ausleihung_id} startet in {time_until_start/3600:.1f} Stunden ({start_time}), noch geplant") new_status = 'planned' - # Wenn kein Enddatum gesetzt ist oder die aktuelle Zeit vor dem Enddatum liegt, - # ist die Ausleihung aktiv - elif not end_time or current_time < end_time: - new_status = 'active' - # Wenn die aktuelle Zeit nach dem Enddatum liegt, ist die Ausleihung beendet + # Eine aktive Ausleihe bleibt aktiv, bis die Rückgabe ausdrücklich bestätigt + # wurde. Das geplante Ende löst nur einen Rückgabehinweis aus. else: - new_status = 'completed' + new_status = 'active' # Protokollieren Sie Statusänderungen, wenn aktiviert und eine Änderung stattgefunden hat if log_changes and new_status != original_status and '_id' in ausleihung: diff --git a/Web/templates/my_borrowed_items.html b/Web/templates/my_borrowed_items.html index 7c9205d..ad4b5b6 100755 --- a/Web/templates/my_borrowed_items.html +++ b/Web/templates/my_borrowed_items.html @@ -142,6 +142,9 @@ {% if item.ActiveAppointment %} Aktiv {% endif %} + {% if item.ReturnPending %} + RÜCKGABE ERFORDERLICH + {% endif %}
@@ -202,7 +205,9 @@
- +
@@ -497,6 +502,30 @@ document.addEventListener('DOMContentLoaded', function() { .return-button:hover { background-color: #0069d9; } + + .return-due-badge { + background-color: #dc3545; + color: #fff; + padding: 4px 9px; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 800; + margin-left: 10px; + } + + .return-button-due { + background-color: #dc3545; + animation: return-due-pulse 1.8s ease-in-out infinite; + } + + .return-button-due:hover { + background-color: #b02a37; + } + + @keyframes return-due-pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(220, 53, 69, 0.45); } + 50% { box-shadow: 0 0 0 7px rgba(220, 53, 69, 0); } + } .return-count-group { display: flex;