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
### ✅ 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
+136 -4
View File
@@ -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,6 +1583,22 @@ def update_appointment_statuses():
except Exception as e:
app.logger.warning(f"Could not update item status to True for {appointment['_id']}: {e}")
# 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(
@@ -1506,7 +1624,7 @@ def update_appointment_statuses():
# -----------------------------------------------------------------
# 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)
+5 -7
View File
@@ -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:
+30 -1
View File
@@ -142,6 +142,9 @@
{% if item.ActiveAppointment %}
<span class="appointment-status status-active">Aktiv</span>
{% endif %}
{% if item.ReturnPending %}
<span class="return-due-badge">RÜCKGABE ERFORDERLICH</span>
{% endif %}
</div>
<div class="item-details">
@@ -202,7 +205,9 @@
<div class="action-buttons-container">
<form method="POST" action="{{ url_for('zurueckgeben', id=item._id) }}">
<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 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>
@@ -498,6 +503,30 @@ document.addEventListener('DOMContentLoaded', function() {
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;
align-items: center;