Die blockierende Ausleihverarbeitung wurde korrigiert.

Ä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
This commit is contained in:
2026-10-15 21:33:41 +02:00
parent b3e79a65c1
commit dffcfdcba7
4 changed files with 200 additions and 35 deletions
+8 -2
View File
@@ -20,13 +20,19 @@ pytest test_ausleihung.py -vv --tb=long
## Test Coverage ## Test Coverage
### ✅ Status Determination (5 tests) ### ✅ Status Determination
- Future borrowings marked as 'planned' - Future borrowings marked as 'planned'
- Current borrowings marked as 'active' - 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 - Cancelled status never changes
- Active borrowings without end time - 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 Operations (2 tests)
- Create immediately active borrowing - Create immediately active borrowing
- Create planned/future borrowing - Create planned/future borrowing
+157 -25
View File
@@ -1382,6 +1382,10 @@ def update_appointment_statuses():
for appointment in appointments_to_check: for appointment in appointments_to_check:
old_status = appointment.get('Status') old_status = appointment.get('Status')
activation_user = str(appointment.get('User') or '').strip() 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') activation_item_name = str(appointment.get('Item') or 'Termin')
# is_library_item expects an item document, not the stored item id. # 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) 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') 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 # Wenn sich der Status geändert hat, aktualisiere in der Datenbank
if new_status != old_status and not item_is_library: if new_status != old_status and not item_is_library:
extra_fields = {} extra_fields = {}
@@ -1481,32 +1583,48 @@ def update_appointment_statuses():
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}")
if old_status == 'planned' and new_status == 'active' and activation_user: # Endzeitpunkt erreicht: nur einen deutlich sichtbaren
try: # Rückgabehinweis setzen, niemals automatisch abschließen.
_create_notification( if (
db, old_status == 'active'
audience='user', and isinstance(appointment.get('End'), datetime.datetime)
notif_type='appointment_activated', and au.ensure_timezone_aware(appointment['End']) <= current_time
title='Reservierung ist jetzt aktiv', ):
message=f"Deine geplante Ausleihe für {activation_item_name} startet jetzt.", ausleihungen.update_one(
target_user=activation_user, {'_id': appointment['_id'], 'Status': 'active'},
reference={ {'$set': {
'appointment_id': str(appointment.get('_id')), 'ReturnPending': True,
'item_id': str(appointment.get('Item') or ''), 'ReturnReminderAt': current_time,
'event': 'activated', 'LastUpdated': current_time,
}, }}
unique_key=f"appointment:activated:{appointment.get('_id')}", )
severity='info',
) if old_status == 'planned' and new_status == 'active' and activation_user:
except Exception as notif_err: try:
app.logger.warning( _create_notification(
f"Failed to create activation notification for {appointment.get('_id')}: {notif_err}" 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) # 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 appt = appointment # Verwende das aktuelle Dokument aus der Schleife
if appt.get('Status') != 'active': if appt.get('Status') != 'active':
continue continue
@@ -7737,7 +7855,13 @@ def zurueckgeben(id):
current_permissions = us.get_effective_permissions(session['username']) 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: try:
# Get ALL active borrowing records for this item and complete them # Get ALL active borrowing records for this item and complete them
client = MongoClient(MONGODB_HOST, MONGODB_PORT) client = MongoClient(MONGODB_HOST, MONGODB_PORT)
@@ -7746,7 +7870,7 @@ def zurueckgeben(id):
# Find all active records for this item # Find all active records for this item
active_records = ausleihungen.find({ active_records = ausleihungen.find({
'Item': id, '$or': [{'Item': id}, {'Item': ObjectId(id) if ObjectId.is_valid(id) else id}],
'Status': 'active' 'Status': 'active'
}) })
@@ -7762,6 +7886,7 @@ def zurueckgeben(id):
{'$set': { {'$set': {
'Status': 'completed', 'Status': 'completed',
'End': end_date, 'End': end_date,
'ReturnPending': False,
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin")) 'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
}} }}
) )
@@ -11437,14 +11562,21 @@ def my_borrowed_items():
'notes': appointment.get('Notes'), 'notes': appointment.get('Notes'),
'period': appointment.get('Period'), 'period': appointment.get('Period'),
'status': appointment.get('VerifiedStatus', appointment.get('Status')), 'status': appointment.get('VerifiedStatus', appointment.get('Status')),
'return_pending': bool(appointment.get('ReturnPending')),
} }
status = appointment.get('Status') status = appointment.get('Status')
if status == 'active': if status == 'active':
item_obj['ActiveAppointment'] = True item_obj['ActiveAppointment'] = True
item_obj['ReturnPending'] = bool(appointment.get('ReturnPending'))
if str(item_obj['_id']) not in processed_item_ids: if str(item_obj['_id']) not in processed_item_ids:
active_items.append(item_obj) active_items.append(item_obj)
processed_item_ids.add(str(item_obj['_id'])) 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': elif status == 'planned':
planned_items.append(item_obj) planned_items.append(item_obj)
+5 -7
View File
@@ -15,7 +15,8 @@ Hauptfunktionen:
Sammlungsstruktur: Sammlungsstruktur:
- ausleihungen: Speichert alle Ausleihungsdatensätze mit ihrem Status - 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 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] 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") print(f"[DEBUG] Ausleihe {ausleihung_id} startet in {time_until_start/3600:.1f} Stunden ({start_time}), noch geplant")
new_status = 'planned' new_status = 'planned'
# Wenn kein Enddatum gesetzt ist oder die aktuelle Zeit vor dem Enddatum liegt, # Eine aktive Ausleihe bleibt aktiv, bis die Rückgabe ausdrücklich bestätigt
# ist die Ausleihung aktiv # wurde. Das geplante Ende löst nur einen Rückgabehinweis aus.
elif not end_time or current_time < end_time:
new_status = 'active'
# Wenn die aktuelle Zeit nach dem Enddatum liegt, ist die Ausleihung beendet
else: else:
new_status = 'completed' new_status = 'active'
# Protokollieren Sie Statusänderungen, wenn aktiviert und eine Änderung stattgefunden hat # Protokollieren Sie Statusänderungen, wenn aktiviert und eine Änderung stattgefunden hat
if log_changes and new_status != original_status and '_id' in ausleihung: if log_changes and new_status != original_status and '_id' in ausleihung:
+30 -1
View File
@@ -142,6 +142,9 @@
{% if item.ActiveAppointment %} {% if item.ActiveAppointment %}
<span class="appointment-status status-active">Aktiv</span> <span class="appointment-status status-active">Aktiv</span>
{% endif %} {% endif %}
{% if item.ReturnPending %}
<span class="return-due-badge">RÜCKGABE ERFORDERLICH</span>
{% endif %}
</div> </div>
<div class="item-details"> <div class="item-details">
@@ -202,7 +205,9 @@
<div class="action-buttons-container"> <div class="action-buttons-container">
<form method="POST" action="{{ url_for('zurueckgeben', id=item._id) }}"> <form method="POST" action="{{ url_for('zurueckgeben', id=item._id) }}">
<input type="hidden" name="source_page" value="my_borrowed_items"> <input type="hidden" name="source_page" value="my_borrowed_items">
<button class="return-button" type="submit">Zurückgeben</button> <button class="return-button{% if item.ReturnPending %} return-button-due{% endif %}" type="submit">
{% if item.ReturnPending %}Rückgabe bestätigen{% else %}Zurückgeben{% endif %}
</button>
</form> </form>
<form method="POST" action="{{ url_for('cancel_ausleihung_route', id=item.AppointmentData.id) }}" onsubmit="return confirm('Möchten Sie diese aktive Ausleihung wirklich stornieren?');"> <form method="POST" action="{{ url_for('cancel_ausleihung_route', id=item.AppointmentData.id) }}" onsubmit="return confirm('Möchten Sie diese aktive Ausleihung wirklich stornieren?');">
<button class="cancel-button" type="submit">Stornieren</button> <button class="cancel-button" type="submit">Stornieren</button>
@@ -497,6 +502,30 @@ document.addEventListener('DOMContentLoaded', function() {
.return-button:hover { .return-button:hover {
background-color: #0069d9; 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 { .return-count-group {
display: flex; display: flex;