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;