Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 79cf958c3c | |||
| 826db109c3 | |||
| f9b991d991 | |||
| 363f0df530 | |||
| 34bb3cf4c3 | |||
| 18706c6d1c | |||
| 9771b98075 | |||
| e380ad6092 | |||
| d6e8e08b10 | |||
| ddb6ab82ad | |||
| baefc55257 | |||
| 6c359089cd | |||
| 479cf3d98c | |||
| f00e7d8f74 | |||
| 7af729ac88 | |||
| 847349b419 | |||
| 91a80c6330 | |||
| 530d71ac8f | |||
| 64991326e7 | |||
| dffcfdcba7 | |||
| b3e79a65c1 | |||
| b33b4eadc6 | |||
| 07a1cdb063 | |||
| 2942bcc380 | |||
| 349337e75f | |||
| 9bdd8261fe | |||
| 04c8505531 | |||
| c9edf4a4c8 | |||
| 0e0a99569e | |||
| 6e94ca20c4 | |||
| 72fdfb0898 | |||
| 9de09a2f41 | |||
| 3d5a81824f | |||
| b0344627a7 |
@@ -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
|
||||
|
||||
+337
-243
@@ -158,6 +158,19 @@ def print(*args, **kwargs):
|
||||
STUDENT_CARD_ENCRYPTED_FIELDS = ('ausweis_ident', 'SchülerName', 'Klasse', 'Notizen')
|
||||
|
||||
|
||||
def _display_username(user_value):
|
||||
"""Return a username for a stored user ID, with legacy value support."""
|
||||
if user_value is None or str(user_value).strip() == '':
|
||||
return ''
|
||||
|
||||
raw_value = str(user_value).strip()
|
||||
user_doc = us.get_user_by_id(raw_value)
|
||||
if user_doc:
|
||||
return str(user_doc.get('Username') or user_doc.get('username') or raw_value)
|
||||
|
||||
return decrypt_text(raw_value)
|
||||
|
||||
|
||||
def _decrypt_student_card_doc(card_doc):
|
||||
if not card_doc:
|
||||
return card_doc
|
||||
@@ -468,6 +481,10 @@ def _enforce_active_session_user():
|
||||
return None
|
||||
|
||||
username = session.get('username')
|
||||
current_user_id = session.get('user_id')
|
||||
if not current_user_id:
|
||||
current_user = us.get_user(username)
|
||||
current_user_id = str(current_user['_id']) if current_user and current_user.get('_id') else None
|
||||
if not username:
|
||||
return None
|
||||
|
||||
@@ -1208,7 +1225,7 @@ def create_return_reminders():
|
||||
|
||||
# Falls der Benutzername verschlüsselt ist, hier entschlüsseln,
|
||||
# damit der Admin den echten Namen/Ausweis lesen kann.
|
||||
display_user = decrypt_text(username) if 'decrypt_text' in globals() else username
|
||||
display_user = _display_username(username)
|
||||
|
||||
item_id = str(borrow_doc.get('Item', '')).strip()
|
||||
item_doc = item_docs.get(item_id, {})
|
||||
@@ -1378,6 +1395,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 = _display_username(activation_user)
|
||||
except Exception:
|
||||
pass
|
||||
activation_item_name = str(appointment.get('Item') or 'Termin')
|
||||
|
||||
# is_library_item expects an item document, not the stored item id.
|
||||
@@ -1400,9 +1421,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 = _display_username(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 = _display_username(active_holder['User'])
|
||||
except Exception:
|
||||
holder = str(active_holder['User'])
|
||||
requested_user = str(appointment.get('User') or '').strip()
|
||||
try:
|
||||
requested_user = _display_username(requested_user)
|
||||
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 = {}
|
||||
@@ -1477,32 +1596,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
|
||||
@@ -1525,7 +1660,7 @@ def update_appointment_statuses():
|
||||
raw_user = str(appt.get('User', '')).strip()
|
||||
|
||||
# Entschlüsselung der AusweisId
|
||||
decrypted_user = decrypt_text(raw_user) if 'decrypt_text' in globals() else raw_user
|
||||
decrypted_user = _display_username(raw_user)
|
||||
target_ausweis_id = decrypted_user if decrypted_user else raw_user
|
||||
item_id = appt.get('Item')
|
||||
|
||||
@@ -1560,7 +1695,7 @@ def update_appointment_statuses():
|
||||
pass
|
||||
|
||||
# STUFE 2: >= 2 * student_ausleih_dauer Tage überfällig -> Ausweis sperren, Stufe 2 setzen & Admins benachrichtigen
|
||||
if days_overdue >= (int(student_ausleih_dauer) * 2) and mahnstufe < 2:
|
||||
if days_overdue >= (int(student_ausleih_dauer) * 2) and mahnstufe == 1 and card.get('is_blocked') is not True:
|
||||
ausleihungen.update_one(
|
||||
{'_id': appt['_id']},
|
||||
{'$set': {'Mahnstufe': 2, 'LastUpdated': current_time}}
|
||||
@@ -3506,144 +3641,6 @@ def library_loans_admin():
|
||||
if client:
|
||||
client.close()
|
||||
|
||||
|
||||
def generate_test_ausleihen():
|
||||
"""
|
||||
Generiert Testdaten für die Überprüfung der Mahnungs- und Sperrlogik.
|
||||
Erstellt Dummy-Schülerausweise, Bibliotheks-Items und Ausleihungen mit manipulierten Daten (DueDate).
|
||||
"""
|
||||
current_time = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
|
||||
try:
|
||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||
db = client[MONGODB_DB]
|
||||
ausleihungen = db['ausleihungen']
|
||||
student_cards_col = db['student_cards']
|
||||
items_col = db['items']
|
||||
|
||||
print("Generiere Testdaten für das Mahnungssystem...")
|
||||
|
||||
# 1. Test-Schülerausweise erstellen oder aktualisieren (upsert)
|
||||
# Wir nutzen AusweisIds, die im System als Identifikation dienen
|
||||
test_students = [
|
||||
{
|
||||
"AusweisId": "TEST-AUSWEIS-01",
|
||||
"SchülerName": encrypt_text("Max Mustermann (Stufe 1 Test)"),
|
||||
"Klasse": "9a",
|
||||
"is_blocked": False
|
||||
},
|
||||
{
|
||||
"AusweisId": "TEST-AUSWEIS-02",
|
||||
"SchülerName": encrypt_text("Erika Musterfrau (Stufe 2 Test)"),
|
||||
"Klasse": "10b",
|
||||
"is_blocked": False
|
||||
},
|
||||
{
|
||||
"AusweisId": "TEST-AUSWEIS-03",
|
||||
"SchülerName": encrypt_text("Tom Frisch (Keine Mahnung Test)"),
|
||||
"Klasse": "8c",
|
||||
"is_blocked": False
|
||||
}
|
||||
]
|
||||
|
||||
for s in test_students:
|
||||
student_cards_col.update_one(
|
||||
{'AusweisId': s['AusweisId']},
|
||||
{'$set': s},
|
||||
upsert=True
|
||||
)
|
||||
|
||||
# 2. Test-Items als Bibliotheksartikel erstellen (ItemType wird gesetzt, damit it.is_library_itemgreift)
|
||||
# Passe LIBRARY_ITEM_TYPES an deine verwendeten Typen an (hier beispielhaft 'Buch' / 'Medien')
|
||||
item_type = LIBRARY_ITEM_TYPES[0] if 'LIBRARY_ITEM_TYPES' in globals() and LIBRARY_ITEM_TYPES else 'Buch'
|
||||
|
||||
item1_res = items_col.insert_one({
|
||||
"Name": "Biologie Buch (Test Stufe 1)",
|
||||
"ItemType": item_type,
|
||||
"Verfuegbar": False,
|
||||
"Exemplare": 1,
|
||||
"Code_4": "TR-9988"
|
||||
})
|
||||
item2_res = items_col.insert_one({
|
||||
"Name": "iPad Pro (Test Stufe 2)",
|
||||
"ItemType": item_type,
|
||||
"Verfuegbar": False,
|
||||
"Exemplare": 1,
|
||||
"Code_4": "TR-9988"
|
||||
})
|
||||
item3_res = items_col.insert_one({
|
||||
"Name": "Taschenrechner (Test Frisch)",
|
||||
"ItemType": item_type,
|
||||
"Verfuegbar": False,
|
||||
"Exemplare": 1,
|
||||
"Code_4": "TR-9988"
|
||||
})
|
||||
|
||||
# 3. Test-Ausleihungen generieren
|
||||
# Fall 1: 15 Tage drüber, Mahnstufe 0 -> Sollte Stufe 1 triggern (>= 14 Tage)
|
||||
due_date_stufe1 = current_time - datetime.timedelta(days=15)
|
||||
|
||||
# Fall 2: 29 Tage drüber, Mahnstufe 1 -> Sollte Stufe 2 triggern & Ausweis sperren (>= 28 Tage)
|
||||
due_date_stufe2 = current_time - datetime.timedelta(days=29)
|
||||
|
||||
# Fall 3: 5 Tage drüber, Mahnstufe 0 -> Sollte NICHTS tun (da < 14 Tage)
|
||||
due_date_frisch = current_time - datetime.timedelta(days=5)
|
||||
|
||||
# Je nachdem, ob dein System die User-Felder in Ausleihungen verschlüsselt speichert:
|
||||
# Hier wird encrypt_text verwendet, falls die Funktion im Scope liegt.
|
||||
user_val_1 = encrypt_text("TEST-AUSWEIS-01")
|
||||
user_val_2 = encrypt_text("TEST-AUSWEIS-02")
|
||||
user_val_3 = encrypt_text("TEST-AUSWEIS-03")
|
||||
|
||||
test_ausleihungen = [
|
||||
{
|
||||
"User": user_val_1,
|
||||
"Item": str(item1_res.inserted_id),
|
||||
"Status": "active",
|
||||
"DueDate": due_date_stufe1,
|
||||
"Mahnstufe": 0,
|
||||
"LastUpdated": current_time
|
||||
},
|
||||
{
|
||||
"User": user_val_2,
|
||||
"Item": str(item2_res.inserted_id),
|
||||
"Status": "active",
|
||||
"DueDate": due_date_stufe2,
|
||||
"Mahnstufe": 1, # Hat bereits Stufe 1, wartet auf Eskalation zu Stufe 2
|
||||
"LastUpdated": due_date_stufe2
|
||||
},
|
||||
{
|
||||
"User": user_val_3,
|
||||
"Item": str(item3_res.inserted_id),
|
||||
"Status": "active",
|
||||
"DueDate": due_date_frisch,
|
||||
"Mahnstufe": 2,
|
||||
"LastUpdated": current_time
|
||||
}
|
||||
]
|
||||
|
||||
# In die Datenbank einfügen
|
||||
ausleihungen.insert_many(test_ausleihungen)
|
||||
|
||||
print("Erfolgreich! Folgende Testdaten wurden für das Mahnungssystem in die DB geschrieben:")
|
||||
print(f"- Fall 1 (Mahnstufe 1 Trigger): Ausweis 'TEST-AUSWEIS-01', fällig war am {due_date_stufe1.strftime('%Y-%m-%d')} (15 Tage überfällig)")
|
||||
print(f"- Fall 2 (Mahnstufe 2 & Sperre): Ausweis 'TEST-AUSWEIS-02', fällig war am {due_date_stufe2.strftime('%Y-%m-%d')} (29 Tage überfällig)")
|
||||
print(f"- Fall 3 (Zu früh / Ignorieren): Ausweis 'TEST-AUSWEIS-03', fällig war am {due_date_frisch.strftime('%Y-%m-%d')} (5 Tage überfällig)")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Fehler beim Erstellen der Testdaten: {e}")
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
|
||||
@app.route('/test_mahnungen')
|
||||
def test_mahnungen():
|
||||
# Testdaten (Mock-Daten), um alle if/else Bedingungen im HTML zu testen
|
||||
generate_test_ausleihen()
|
||||
create_return_reminders()
|
||||
return redirect(url_for('mahnungen_admin'))
|
||||
|
||||
|
||||
@app.route('/mahnungen_admin')
|
||||
def mahnungen_admin():
|
||||
"""Admin-Übersicht für überfällige Bibliotheks-Ausleihen."""
|
||||
@@ -3763,8 +3760,8 @@ def mahnungen_admin():
|
||||
email_service_enabled=cfg.EMAIL_ENABLED
|
||||
)
|
||||
|
||||
@app.route('/mahnungen_reset', methods=['POST'])
|
||||
def mahnungen_reset():
|
||||
@app.route('/mahnungen_reset/<int:days>', methods=['POST'])
|
||||
def mahnungen_reset(days):
|
||||
"""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
|
||||
@@ -3792,7 +3789,7 @@ def mahnungen_reset():
|
||||
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
|
||||
new_due_date = current_time + datetime.timedelta(days=days) # Frist um die angegebene Anzahl an Tagen verlängern
|
||||
|
||||
# 2. Mahnstufe zurücksetzen und Frist verlängern
|
||||
ausleihungen_col.update_one(
|
||||
@@ -3826,7 +3823,7 @@ def mahnungen_reset():
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Mahnstufe zurückgesetzt, Frist um 14 Tage verlängert und Ausweis entsperrt.'
|
||||
'message': f'Mahnstufe zurückgesetzt, Frist um {days} Tage verlängert und Ausweis entsperrt.'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
@@ -4365,10 +4362,17 @@ def api_library_scan_action():
|
||||
|
||||
due_date = now + datetime.timedelta(days=borrow_duration_days)
|
||||
|
||||
app.logger.info(f"Library scan action: borrowing item {item_id} for student card {student_card_id} until {due_date.isoformat()}")
|
||||
borrower_user = us.get_user_by_student_card(student_card_id)
|
||||
app.logger.info(f"Found borrower user: {borrower_user}")
|
||||
if not borrower_user or not borrower_user.get('_id'):
|
||||
app.logger.warning(f"No user found for student card {student_card_id}. Cannot record borrowing in ausleihungen.")
|
||||
return jsonify({'ok': False, 'message': 'Kein Benutzer für diesen Schülerausweis gefunden.'}), 404
|
||||
|
||||
it.update_item_status(item_id, False, borrower_name)
|
||||
au.add_ausleihung(
|
||||
item_id,
|
||||
borrower_name,
|
||||
str(borrower_user['_id']),
|
||||
now,
|
||||
end_date=due_date,
|
||||
due_date=due_date,
|
||||
@@ -4380,7 +4384,7 @@ def api_library_scan_action():
|
||||
'channel': 'library_scan',
|
||||
'item_id': item_id,
|
||||
'item_name': item_doc.get('Name', ''),
|
||||
'borrower':borrower_name,
|
||||
'borrower': borrower_name,
|
||||
'student_card_id': student_card_id,
|
||||
'borrow_duration_days': borrow_duration_days,
|
||||
}
|
||||
@@ -4470,6 +4474,7 @@ def api_item_detail(item_id):
|
||||
db = client[MONGODB_DB]
|
||||
ausleihungen_col = db['ausleihungen']
|
||||
items_col = db['items']
|
||||
student_cards_col = db['student_cards']
|
||||
|
||||
# Determine parent/group id (if this is a child, use its ParentItemId)
|
||||
parent_id = str(item.get('ParentItemId') or str(item.get('_id')))
|
||||
@@ -4506,23 +4511,32 @@ def api_item_detail(item_id):
|
||||
else:
|
||||
status_label = 'Verfügbar'
|
||||
|
||||
# Helper function to get borrower display name
|
||||
def get_borrower_name(user_raw):
|
||||
if not user_raw:
|
||||
return ''
|
||||
try:
|
||||
user_doc = student_cards_col.find_one({'_id': ObjectId(user_raw)})
|
||||
if user_doc:
|
||||
if user_doc.get('SchülerName'):
|
||||
return decrypt_text(str(user_doc.get('SchülerName', '')))
|
||||
return str(user_doc.get('AusweisId', ''))
|
||||
except Exception:
|
||||
pass
|
||||
return str(user_raw)
|
||||
|
||||
# Prefer the most recent active borrower across the group
|
||||
borrower_value = ''
|
||||
for rec in borrow_records:
|
||||
if rec.get('Status') == 'active' and rec.get('User'):
|
||||
try:
|
||||
user_raw = rec.get('User')
|
||||
if user_raw is not None:
|
||||
borrower_value = decrypt_text(str(user_raw))
|
||||
except Exception:
|
||||
borrower_value = str(rec.get('User', ''))
|
||||
borrower_value = get_borrower_name(rec.get('User'))
|
||||
break
|
||||
|
||||
if not borrower_value and item.get('User'):
|
||||
try:
|
||||
item_user = item.get('User')
|
||||
if item_user is not None:
|
||||
borrower_value = decrypt_text(str(item_user))
|
||||
borrower_value = _display_username(item_user)
|
||||
except Exception:
|
||||
borrower_value = str(item.get('User', ''))
|
||||
|
||||
@@ -4538,17 +4552,13 @@ def api_item_detail(item_id):
|
||||
rows = []
|
||||
|
||||
for rec in borrow_records[:3]:
|
||||
user_raw = rec.get('User')
|
||||
try:
|
||||
user = decrypt_text(user_raw) if user_raw is not None else ''
|
||||
except Exception:
|
||||
user = user_raw
|
||||
rec_borrower_name = get_borrower_name(rec.get('User')) or '-'
|
||||
status = rec.get('Status', '')
|
||||
start = fmt_dt(rec.get('Start'))
|
||||
end = fmt_dt(rec.get('End'))
|
||||
notes = html.escape(str(rec.get('Notes') or ''))
|
||||
rows.append(
|
||||
f"<li><strong>{html.escape(str(user or '-'))}</strong> — "
|
||||
f"<li><strong>{html.escape(rec_borrower_name)}</strong> — "
|
||||
f"{html.escape(str(status))} — "
|
||||
f"{html.escape(str(start))} → {html.escape(str(end))}"
|
||||
f"{(' — ' + notes) if notes else ''}</li>"
|
||||
@@ -5605,6 +5615,7 @@ def login():
|
||||
if user:
|
||||
app.logger.info(f"Login success: username={encrypt_text(username)!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={encrypt_text(request.remote_addr)}")
|
||||
session['username'] = username
|
||||
session['user_id'] = str(user.get('_id'))
|
||||
is_admin_user = bool(user.get('Admin', False))
|
||||
session['admin'] = is_admin_user
|
||||
session['is_admin'] = is_admin_user
|
||||
@@ -5735,6 +5746,8 @@ def get_items():
|
||||
favorites = set(session.get('favorites', []))
|
||||
|
||||
available_only = str(request.args.get('available_only', '')).strip().lower() in ('1', 'true', 'yes', 'on')
|
||||
search_term = str(request.args.get('search', '')).strip()
|
||||
search_scope = str(request.args.get('search_scope', 'all')).strip().lower()
|
||||
offset_raw = request.args.get('offset')
|
||||
limit_raw = request.args.get('limit')
|
||||
light_mode_param = str(request.args.get('light_mode', '')).strip().lower()
|
||||
@@ -5770,6 +5783,42 @@ def get_items():
|
||||
'Deleted': {'$ne': True},
|
||||
}
|
||||
|
||||
if search_term:
|
||||
search_pattern = re.escape(search_term[:200])
|
||||
if search_scope in ('description', 'title_description'):
|
||||
search_fields = ['Name', 'Beschreibung']
|
||||
else:
|
||||
search_fields = [
|
||||
'Name', 'Beschreibung', 'Code_4', 'ISBN',
|
||||
'Author', 'Autor',
|
||||
]
|
||||
|
||||
direct_matches = [{field: {'$regex': search_pattern, '$options': 'i'}}
|
||||
for field in search_fields]
|
||||
if search_scope not in ('description', 'title_description') and search_term.isdigit():
|
||||
direct_matches.append({'Code_4': int(search_term)})
|
||||
grouped_parent_ids = [
|
||||
child.get('ParentItemId')
|
||||
for child in items_col.find(
|
||||
{
|
||||
'IsGroupedSubItem': True,
|
||||
'Deleted': {'$ne': True},
|
||||
'$or': direct_matches,
|
||||
},
|
||||
{'ParentItemId': 1},
|
||||
)
|
||||
if child.get('ParentItemId')
|
||||
]
|
||||
if grouped_parent_ids:
|
||||
direct_matches.append({'_id': {'$in': grouped_parent_ids}})
|
||||
|
||||
base_query = {
|
||||
'$and': [
|
||||
base_query,
|
||||
{'$or': direct_matches},
|
||||
]
|
||||
}
|
||||
|
||||
total_count = items_col.count_documents(base_query)
|
||||
|
||||
light_projection = {
|
||||
@@ -5836,6 +5885,7 @@ def get_items():
|
||||
'ParentItemId': 1,
|
||||
'Code_4': 1,
|
||||
'Verfuegbar': 1,
|
||||
'User': 1,
|
||||
'Name': 1,
|
||||
}
|
||||
# Full mode: complete child data with all details
|
||||
@@ -5844,6 +5894,7 @@ def get_items():
|
||||
'ParentItemId': 1,
|
||||
'Code_4': 1,
|
||||
'Verfuegbar': 1,
|
||||
'User': 1,
|
||||
'Name': 1,
|
||||
'Images': 1,
|
||||
'ThumbnailInfo': 1,
|
||||
@@ -5871,13 +5922,26 @@ def get_items():
|
||||
grouped_units = [itm] + grouped_children
|
||||
available_units = []
|
||||
grouped_all_codes = []
|
||||
grouped_unit_states = []
|
||||
for unit in grouped_units:
|
||||
unit_id = str(unit['_id']) if not isinstance(unit['_id'], str) else unit['_id']
|
||||
unit_code = unit.get('Code_4')
|
||||
if unit_code is not None and str(unit_code).strip() != '':
|
||||
grouped_all_codes.append(str(unit_code).strip())
|
||||
normalized_code = str(unit_code).strip()
|
||||
grouped_all_codes.append(normalized_code)
|
||||
else:
|
||||
normalized_code = ''
|
||||
|
||||
grouped_unit_states.append({
|
||||
'id': unit_id,
|
||||
'code': normalized_code,
|
||||
'available': bool(unit.get('Verfuegbar', True)),
|
||||
'borrowed_by_current_user': bool(
|
||||
username and str(unit.get('User') or '') == str(username)
|
||||
),
|
||||
})
|
||||
|
||||
if unit.get('Verfuegbar', True):
|
||||
unit_id = str(unit['_id']) if not isinstance(unit['_id'], str) else unit['_id']
|
||||
code = unit.get('Code_4') or '-'
|
||||
available_units.append({
|
||||
'id': unit_id,
|
||||
@@ -5890,6 +5954,7 @@ def get_items():
|
||||
itm['AvailableGroupedCount'] = len(available_units)
|
||||
itm['GroupedAvailableUnits'] = available_units
|
||||
itm['GroupedAllCodes'] = grouped_all_codes
|
||||
itm['GroupedUnitStates'] = grouped_unit_states
|
||||
if grouped_count > 1:
|
||||
itm['Verfuegbar'] = len(available_units) > 0
|
||||
if available_only and not itm.get('Verfuegbar', False):
|
||||
@@ -5951,10 +6016,7 @@ def get_bookings():
|
||||
result = []
|
||||
for booking in bookings:
|
||||
raw_booking_user = booking.get('User') or ''
|
||||
try:
|
||||
booking_user = decrypt_text(raw_booking_user) if raw_booking_user else ''
|
||||
except Exception:
|
||||
booking_user = str(raw_booking_user)
|
||||
booking_user = str(raw_booking_user)
|
||||
start_dt = booking.get('Start')
|
||||
if not start_dt:
|
||||
continue
|
||||
@@ -6000,7 +6062,7 @@ def get_bookings():
|
||||
'userName': str(booking_user),
|
||||
'notes': str(booking.get('Notes') or ''),
|
||||
'period': period,
|
||||
'isCurrentUser': str(booking_user) == username,
|
||||
'isCurrentUser': str(booking_user) == str(current_user_id),
|
||||
'itemBorrower': item_borrower,
|
||||
})
|
||||
|
||||
@@ -6120,7 +6182,10 @@ def api_booking_conflicts():
|
||||
current_permissions = us.get_effective_permissions(session['username'])
|
||||
|
||||
if not current_permissions['actions'].get('can_view_logs', False):
|
||||
query['User'] = session['username']
|
||||
current_user = us.get_user(session['username'])
|
||||
if not current_user or not current_user.get('_id'):
|
||||
return jsonify({'error': 'User account could not be resolved'}), 401
|
||||
query['User'] = str(current_user['_id'])
|
||||
|
||||
conflicts = list(ausleihungen.find(query))
|
||||
result = []
|
||||
@@ -7461,6 +7526,11 @@ def ausleihen(id):
|
||||
return redirect(url_for('login'))
|
||||
|
||||
username = session['username']
|
||||
current_user = us.get_user(username)
|
||||
if not current_user or not current_user.get('_id'):
|
||||
flash('Benutzerkonto konnte nicht eindeutig ermittelt werden.', 'error')
|
||||
return redirect(url_for('login'))
|
||||
effective_borrower_id = str(current_user['_id'])
|
||||
requested_return_to = (request.form.get('return_to') or '').strip().lower()
|
||||
if requested_return_to == 'library' and cfg.MODULES.is_enabled('library'):
|
||||
redirect_target = 'library_view'
|
||||
@@ -7534,6 +7604,7 @@ def ausleihen(id):
|
||||
flash('Keine Schülerin/kein Schüler mit dieser Ausweis-ID gefunden.', 'error')
|
||||
return redirect(url_for('library_view'))
|
||||
effective_borrower = student_user.get('Username') or student_user.get('username') or username
|
||||
effective_borrower_id = str(student_user['_id'])
|
||||
if borrow_duration_days is None:
|
||||
try:
|
||||
card_default = int(student_user.get('MaxBorrowDays', cfg.STUDENT_DEFAULT_BORROW_DAYS))
|
||||
@@ -7608,7 +7679,7 @@ def ausleihen(id):
|
||||
it.update_item_status(unit_id, False, effective_borrower)
|
||||
au.add_ausleihung(
|
||||
unit_id,
|
||||
effective_borrower,
|
||||
effective_borrower_id,
|
||||
start_date,
|
||||
end_date=end_date,
|
||||
due_date=end_date if is_library_item else None,
|
||||
@@ -7705,7 +7776,7 @@ def ausleihen(id):
|
||||
start_date = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
au.add_ausleihung(
|
||||
id,
|
||||
effective_borrower,
|
||||
effective_borrower_id,
|
||||
start_date,
|
||||
end_date=end_date,
|
||||
due_date=end_date if is_library_item else None,
|
||||
@@ -7756,7 +7827,7 @@ def ausleihen(id):
|
||||
exemplar_id = f"{id}_{exemplar['number']}"
|
||||
au.add_ausleihung(
|
||||
exemplar_id,
|
||||
effective_borrower,
|
||||
effective_borrower_id,
|
||||
start_date,
|
||||
end_date=end_date,
|
||||
due_date=end_date if is_library_item else None,
|
||||
@@ -7807,7 +7878,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)
|
||||
@@ -7816,7 +7893,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'
|
||||
})
|
||||
|
||||
@@ -7832,6 +7909,7 @@ def zurueckgeben(id):
|
||||
{'$set': {
|
||||
'Status': 'completed',
|
||||
'End': end_date,
|
||||
'ReturnPending': False,
|
||||
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
}}
|
||||
)
|
||||
@@ -7917,7 +7995,12 @@ def get_ausleihung_by_item_route(id):
|
||||
|
||||
current_permissions = us.get_effective_permissions(session['username'])
|
||||
|
||||
if ausleihung and (current_permissions['actions'].get('can_manage_users')) or ausleihung.get('User') == session['username']:
|
||||
current_user = us.get_user(session['username'])
|
||||
current_user_id = str(current_user['_id']) if current_user and current_user.get('_id') else ''
|
||||
if ausleihung and (
|
||||
current_permissions['actions'].get('can_manage_users')
|
||||
or ausleihung.get('User') == current_user_id
|
||||
):
|
||||
return {'ausleihung': ausleihung, 'status': 'success'}
|
||||
|
||||
item = it.get_item(id)
|
||||
@@ -7949,10 +8032,7 @@ def get_planned_bookings(item_id):
|
||||
bookings = []
|
||||
for r in cursor:
|
||||
raw_user = r.get('User') or ''
|
||||
try:
|
||||
booking_user = decrypt_text(raw_user) if raw_user else ''
|
||||
except Exception:
|
||||
booking_user = str(raw_user)
|
||||
booking_user = _display_username(raw_user)
|
||||
bookings.append({
|
||||
'id': str(r.get('_id')),
|
||||
'user': booking_user,
|
||||
@@ -7983,10 +8063,7 @@ def get_planned_bookings_public(item_id):
|
||||
bookings = []
|
||||
for r in cursor:
|
||||
raw_user = r.get('User') or ''
|
||||
try:
|
||||
booking_user = decrypt_text(raw_user) if raw_user else ''
|
||||
except Exception:
|
||||
booking_user = str(raw_user)
|
||||
booking_user = _display_username(raw_user)
|
||||
bookings.append({
|
||||
'user': booking_user,
|
||||
'period': r.get('Period'),
|
||||
@@ -8199,7 +8276,7 @@ def plan_booking():
|
||||
try:
|
||||
for _, period, period_times in requested_slots:
|
||||
booking_id = au.add_planned_booking(
|
||||
item_id, session['username'], period_times['start'],
|
||||
item_id, session.get('user_id') or str(us.get_user(session['username'])['_id']), period_times['start'],
|
||||
period_times['end'], notes, period=period
|
||||
)
|
||||
if not booking_id:
|
||||
@@ -8252,7 +8329,7 @@ def process_day_bookings(item_id, booking_date, periods, notes):
|
||||
# Create the booking
|
||||
booking_id = au.add_planned_booking(
|
||||
item_id,
|
||||
session['username'],
|
||||
session.get('user_id') or str(us.get_user(session['username'])['_id']),
|
||||
start_time,
|
||||
end_time,
|
||||
notes,
|
||||
@@ -8319,7 +8396,7 @@ def add_booking():
|
||||
# Continue with adding the booking
|
||||
booking_id = au.add_planned_booking(
|
||||
item_id=item_id,
|
||||
user=session['username'],
|
||||
user_id=session.get('user_id') or str(us.get_user(session['username'])['_id']),
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
notes=notes,
|
||||
@@ -8350,8 +8427,10 @@ def cancel_booking(id):
|
||||
current_permissions = us.get_effective_permissions(session['username'])
|
||||
|
||||
# Check if user owns this booking
|
||||
booking_user = au.dp.decrypt_text(booking.get('User')) if booking.get('User') else ''
|
||||
if booking_user != session['username'] and not current_permissions['actions'].get('can_manage_users', False):
|
||||
current_user = us.get_user(session['username'])
|
||||
current_user_id = str(current_user['_id']) if current_user and current_user.get('_id') else ''
|
||||
booking_user_id = str(booking.get('User') or '')
|
||||
if booking_user_id != current_user_id and not current_permissions['actions'].get('can_manage_users', False):
|
||||
return {"success": False, "error": "Not authorized to cancel this booking"}, 403
|
||||
|
||||
# Cancel the booking
|
||||
@@ -8828,7 +8907,7 @@ def user_del():
|
||||
fullname = None
|
||||
|
||||
users_list.append({
|
||||
'username': decrypt_text(username),
|
||||
'username': _display_username(username),
|
||||
'admin': user.get('Admin', False),
|
||||
'fullname': fullname,
|
||||
'name': name,
|
||||
@@ -8969,7 +9048,8 @@ def admin_borrowings():
|
||||
has_damage = False
|
||||
|
||||
raw_user = r.get('User', '')
|
||||
decrypted_user = decrypt_text(raw_user) if raw_user else ''
|
||||
decrypted_user = _display_username(raw_user)
|
||||
|
||||
|
||||
entries.append({
|
||||
'id': str(r.get('_id')),
|
||||
@@ -10203,8 +10283,8 @@ def library_item_invoices(item_id):
|
||||
entries.append({
|
||||
'borrow_id': str(borrow_doc.get('_id')),
|
||||
'borrow_status': borrow_doc.get('Status', ''),
|
||||
'borrow_user': decrypt_text(borrow_doc.get('User', '')) if hasattr(borrow_doc,
|
||||
'get') and borrow_doc.get(
|
||||
'borrow_user': _display_username(borrow_doc.get('User', '')) if hasattr(borrow_doc,
|
||||
'get') and borrow_doc.get(
|
||||
'User') else '',
|
||||
'borrow_start': borrow_doc.get('Start').strftime('%d.%m.%Y %H:%M') if isinstance(
|
||||
borrow_doc.get('Start'), datetime.datetime) else '',
|
||||
@@ -10304,7 +10384,7 @@ def library_item_invoices(item_id):
|
||||
))
|
||||
|
||||
for b in damage_borrow_docs:
|
||||
borrow_user = decrypt_text(b.get('User', '')) if b.get('User') else '—'
|
||||
borrow_user = _display_username(b.get('User', '')) if b.get('User') else '—'
|
||||
|
||||
d_data = b.get('DamageData') or {}
|
||||
if not isinstance(d_data, dict): d_data = {}
|
||||
@@ -10594,7 +10674,7 @@ def logs():
|
||||
|
||||
formatted_items.append({
|
||||
'Item': item_name,
|
||||
'User': decrypt_text(username),
|
||||
'User': _display_username(username),
|
||||
'Start': start_date,
|
||||
'End': end_date,
|
||||
'Duration': duration,
|
||||
@@ -10636,7 +10716,7 @@ def logs():
|
||||
note = log_item.get('note', '')
|
||||
formatted_items.append({
|
||||
'Item': item_name,
|
||||
'User': decrypt_text(log_item.get('user', 'Unknown User')),
|
||||
'User': _display_username(log_item.get('user', 'Unknown User')),
|
||||
'Start': ts_display,
|
||||
'End': '-',
|
||||
'Duration': '-',
|
||||
@@ -10650,7 +10730,7 @@ def logs():
|
||||
resolved_count = log_item.get('resolved_count', 0)
|
||||
formatted_items.append({
|
||||
'Item': item_name,
|
||||
'User': decrypt_text(log_item.get('user', 'Unknown User')),
|
||||
'User': _display_username(log_item.get('user', 'Unknown User')),
|
||||
'Start': ts_display,
|
||||
'End': '-',
|
||||
'Duration': '-',
|
||||
@@ -10913,7 +10993,9 @@ def search_word(word):
|
||||
if (term_lower in text.lower()) or (term_lower in title_text.lower()):
|
||||
_id = i.get("_id")
|
||||
if _id is not None:
|
||||
id_set.add(str(_id))
|
||||
item_id = str(_id)
|
||||
parent_id = i.get('ParentItemId')
|
||||
id_set.add(str(parent_id or item_id))
|
||||
|
||||
return jsonify({"success": True, "response": list(id_set)})
|
||||
except Exception as e:
|
||||
@@ -11446,6 +11528,13 @@ def my_borrowed_items():
|
||||
return redirect(url_for('login', next=request.path))
|
||||
|
||||
username = session['username']
|
||||
current_user = us.get_user(username) or {}
|
||||
user_ids = {
|
||||
username,
|
||||
str(current_user.get('_id')) if current_user.get('_id') else '',
|
||||
str(session.get('user_id') or ''),
|
||||
}
|
||||
user_ids.discard('')
|
||||
|
||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||
db = client[MONGODB_DB]
|
||||
@@ -11462,31 +11551,27 @@ def my_borrowed_items():
|
||||
|
||||
for appointment in all_ausleihungen:
|
||||
raw_user = appointment.get('User', '')
|
||||
user_values = {str(raw_user).strip()} if raw_user else set()
|
||||
try:
|
||||
decrypted_user = decrypt_text(raw_user) if raw_user else ''
|
||||
except Exception as e:
|
||||
app.logger.error(f"Entschlüsselungsfehler: {e}")
|
||||
decrypted_user = ''
|
||||
if raw_user:
|
||||
user_values.add(str(decrypt_text(raw_user)).strip())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if decrypted_user != username:
|
||||
if not user_values.intersection(user_ids):
|
||||
continue
|
||||
|
||||
item_id = appointment.get('Item')
|
||||
if not item_id:
|
||||
continue
|
||||
|
||||
try:
|
||||
if isinstance(item_id, str):
|
||||
query_id = ObjectId(item_id)
|
||||
else:
|
||||
query_id = item_id
|
||||
|
||||
item_obj = items_collection.find_one({
|
||||
'_id': query_id,
|
||||
'Deleted': {'$ne': True}
|
||||
})
|
||||
except Exception:
|
||||
item_obj = None
|
||||
item_queries = [{'_id': item_id}]
|
||||
if isinstance(item_id, str) and ObjectId.is_valid(item_id):
|
||||
item_queries.insert(0, {'_id': ObjectId(item_id)})
|
||||
item_obj = items_collection.find_one({
|
||||
'$or': item_queries,
|
||||
'Deleted': {'$ne': True}
|
||||
})
|
||||
|
||||
if item_obj:
|
||||
item_obj['_id'] = str(item_obj['_id'])
|
||||
@@ -11498,14 +11583,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)
|
||||
|
||||
@@ -11516,12 +11608,14 @@ def my_borrowed_items():
|
||||
|
||||
for item in all_borrowed_items:
|
||||
raw_item_user = item.get('User', '')
|
||||
item_user_values = {str(raw_item_user).strip()} if raw_item_user else set()
|
||||
try:
|
||||
dec_item_user = decrypt_text(raw_item_user) if raw_item_user else ''
|
||||
if raw_item_user:
|
||||
item_user_values.add(str(decrypt_text(raw_item_user)).strip())
|
||||
except Exception:
|
||||
dec_item_user = ''
|
||||
pass
|
||||
|
||||
if dec_item_user == username and str(item['_id']) not in processed_item_ids:
|
||||
if item_user_values.intersection(user_ids) and str(item['_id']) not in processed_item_ids:
|
||||
item['_id'] = str(item['_id'])
|
||||
item['ActiveAppointment'] = True
|
||||
item['AppointmentData'] = {'status': 'active (no document)'}
|
||||
@@ -11841,10 +11935,10 @@ def admin_damaged_items():
|
||||
|
||||
# Benutzernamen der Ausleihe entschlüsseln
|
||||
if active_borrow and active_borrow.get('User'):
|
||||
active_borrow['User'] = decrypt_text(active_borrow['User'])
|
||||
active_borrow['User'] = _display_username(active_borrow['User'])
|
||||
|
||||
# Direkt am Item hinterlegten Nutzer entschlüsseln
|
||||
item_user = decrypt_text(item.get('User')) if item.get('User') else ''
|
||||
item_user = _display_username(item.get('User')) if item.get('User') else ''
|
||||
|
||||
# Datenstruktur exakt für dein Jinja2 Template aufbauen
|
||||
damaged_items.append({
|
||||
@@ -12281,7 +12375,7 @@ def schedule_appointment():
|
||||
# Use add_ausleihung directly to set the correct initial status
|
||||
appointment_id = au.add_ausleihung(
|
||||
item_id=item_id,
|
||||
user=session['username'],
|
||||
user_id=session.get('user_id') or str(us.get_user(session['username'])['_id']),
|
||||
start_date=start_datetime,
|
||||
end_date=end_datetime,
|
||||
notes=notes,
|
||||
@@ -12417,7 +12511,7 @@ def cancel_ausleihung_route(id):
|
||||
ausleihung_status = ausleihung.get('Status', 'unknown')
|
||||
raw_ausleihung_user = ausleihung.get('User', '')
|
||||
try:
|
||||
ausleihung_user = decrypt_text(raw_ausleihung_user) if raw_ausleihung_user else ''
|
||||
ausleihung_user = _display_username(raw_ausleihung_user)
|
||||
except Exception:
|
||||
ausleihung_user = str(raw_ausleihung_user or '')
|
||||
print(f"Found ausleihung: ID={id}, Status={ausleihung_status}")
|
||||
|
||||
@@ -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:
|
||||
@@ -185,13 +183,13 @@ def create_backup_database():
|
||||
|
||||
# === AUSLEIHUNG MANAGEMENT ===
|
||||
|
||||
def add_ausleihung(item_id, user, start_date, end_date=None, notes="", status="active", period=None, exemplar_data=None, due_date=None):
|
||||
def add_ausleihung(item_id, user_id, start_date, end_date=None, notes="", status="active", period=None, exemplar_data=None, due_date=None):
|
||||
"""
|
||||
Add a new borrowing record for an item.
|
||||
|
||||
Args:
|
||||
item_id (str): ID of the item borrowed
|
||||
user (str): Username of the borrower
|
||||
user_id (str): ID of the borrower
|
||||
start_date (datetime): Start date and time of the borrowing
|
||||
end_date (datetime, optional): End date and time if already returned
|
||||
notes (str, optional): Additional notes about the borrowing
|
||||
@@ -209,7 +207,7 @@ def add_ausleihung(item_id, user, start_date, end_date=None, notes="", status="a
|
||||
|
||||
ausleihung = {
|
||||
'Item': item_id,
|
||||
'User': dp.encrypt_text(user),
|
||||
'User': str(user_id),
|
||||
'Start': start_date,
|
||||
'Status': status
|
||||
}
|
||||
@@ -256,8 +254,7 @@ def update_ausleihung(id, item_id=None, user_id=None, start=None, end=None, note
|
||||
if item_id is not None:
|
||||
update_data['Item'] = item_id
|
||||
if user_id is not None:
|
||||
# WICHTIG: Gleiche Verschlüsselung wie in add_ausleihung nutzen!
|
||||
update_data['User'] = dp.encrypt_text(user_id) if hasattr(dp, 'encrypt_text') else user_id
|
||||
update_data['User'] = str(user_id)
|
||||
if start is not None:
|
||||
update_data['Start'] = ensure_timezone_aware(start)
|
||||
if end is not None:
|
||||
@@ -556,9 +553,7 @@ def get_ausleihung_by_user(user_id, status=None, use_client_side_verification=Tr
|
||||
db = client[cfg.MONGODB_DB]
|
||||
ausleihungen = db['ausleihungen']
|
||||
|
||||
# Ensure proper encrypted query string if user IDs are encrypted in DB
|
||||
search_user = dp.encrypt_text(user_id) if hasattr(dp, 'encrypt_text') else user_id
|
||||
query = {'User': search_user, 'Status': {'$ne': 'deleted'}}
|
||||
query = {'User': str(user_id), 'Status': {'$ne': 'deleted'}}
|
||||
|
||||
if not use_client_side_verification:
|
||||
if status is not None:
|
||||
@@ -864,8 +859,8 @@ def reset_item_completely(item_id):
|
||||
|
||||
# === KOMPATIBILITÄTSFUNKTIONEN ===
|
||||
|
||||
def add_planned_booking(item_id, user, start_date, end_date, notes="", period=None):
|
||||
return add_ausleihung(item_id, user, start_date, end_date, notes, status='planned', period=period)
|
||||
def add_planned_booking(item_id, user_id, start_date, end_date, notes="", period=None):
|
||||
return add_ausleihung(item_id, user_id, start_date, end_date, notes, status='planned', period=period)
|
||||
|
||||
|
||||
def check_booking_conflict(item_id, start_date, end_date, period=None):
|
||||
|
||||
@@ -697,7 +697,7 @@ def get_user_by_student_card(student_card_id):
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
users = db['student_cards']
|
||||
found_user = users.find_one({'SchülerName': normalized})
|
||||
found_user = users.find_one({'AusweisId': normalized})
|
||||
client.close()
|
||||
|
||||
# Do not call dp.decrypt_text() here because found_user is a MongoDB dictionary.
|
||||
@@ -874,6 +874,42 @@ def get_user(username):
|
||||
client.close()
|
||||
|
||||
|
||||
def get_user_by_id(user_id):
|
||||
"""Retrieve a user by MongoDB document ID."""
|
||||
if user_id is None:
|
||||
return None
|
||||
|
||||
lookup_values = [str(user_id)]
|
||||
if ObjectId.is_valid(str(user_id)):
|
||||
lookup_values.insert(0, ObjectId(str(user_id)))
|
||||
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
try:
|
||||
def find_in_db(database_name):
|
||||
users = client[database_name]['users']
|
||||
for lookup_value in lookup_values:
|
||||
user = users.find_one({'_id': lookup_value})
|
||||
if user:
|
||||
return user
|
||||
return None
|
||||
|
||||
tenant_db, tenant_id = _resolve_request_tenant_db()
|
||||
if tenant_db:
|
||||
user = find_in_db(tenant_db)
|
||||
if user:
|
||||
return user
|
||||
|
||||
if _has_tenant_configs() and tenant_db is None:
|
||||
logger.warning(
|
||||
"Refusing default DB fallback for user ID lookup because tenant configs exist and no tenant was resolved."
|
||||
)
|
||||
return None
|
||||
|
||||
return find_in_db(cfg.MONGODB_DB)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def check_admin(username):
|
||||
"""Check if a user has administrator privileges."""
|
||||
user = get_user(username)
|
||||
|
||||
@@ -921,6 +921,42 @@ html, body {
|
||||
}
|
||||
}
|
||||
|
||||
/* iPad layout: keep content airy while using the available tablet width. */
|
||||
@media screen and (min-width: 768px) and (max-width: 1180px) {
|
||||
.container {
|
||||
width: min(100% - 32px, 1120px);
|
||||
margin: 16px auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-control,
|
||||
.form-select,
|
||||
textarea,
|
||||
input,
|
||||
select {
|
||||
min-height: 44px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.btn,
|
||||
button,
|
||||
input[type="submit"],
|
||||
input[type="button"] {
|
||||
min-height: 44px;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: var(--ui-radius);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.table-responsive > .table {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================================= */
|
||||
/* UNIVERSAL DARK MODE OVERRIDES FOR HARDCODED HTML STYLES */
|
||||
/* ========================================================= */
|
||||
|
||||
@@ -1301,8 +1301,6 @@
|
||||
<button class="function-search-btn" type="submit">Los</button>
|
||||
</form>
|
||||
</div>
|
||||
{% if current_tenant_db %}
|
||||
<span class="navbar-text tenant-badge" title="Aktive Tenant-Datenbank">{{ current_tenant_db }}</span>
|
||||
{% endif %}
|
||||
<span class="navbar-text text-light me-3">{{ session['username'] }}</span>
|
||||
<div class="dropdown me-2 user-menu-wrap">
|
||||
@@ -1332,7 +1330,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% if library_module_enabled and CURRENT_MODULE == 'library' %}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark" id="libraryNavbar">
|
||||
|
||||
@@ -1419,6 +1419,7 @@
|
||||
keyboardScannerEnabled = false;
|
||||
keyboardScanBuffer = '';
|
||||
keyboardLastKeyAt = 0;
|
||||
setActiveStudentCard('');
|
||||
document.removeEventListener('keydown', keyboardScanKeydownHandler);
|
||||
const modal = document.getElementById('physicalScannerModal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
|
||||
@@ -187,7 +187,11 @@
|
||||
{% endif %}
|
||||
<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
|
||||
<i class="bi bi-arrow-counterclockwise"></i> Zurücksetzen (14 Tage)
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-warning text-dark"
|
||||
onclick="resetMahnungCustom('{{ item.id }}', '{{ item.schueler_name }}')">
|
||||
<i class="bi bi-arrow-counterclockwise"></i> Zurücksetzen mit eigener Frist
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -210,7 +214,7 @@
|
||||
|
||||
<!-- E-Mail Modal -->
|
||||
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="emailModalLabel">Mahnungs-E-Mail senden</h5>
|
||||
@@ -228,7 +232,7 @@
|
||||
<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>
|
||||
<label for="modalMessage" class="form-label">Nachrichten-Text</label>
|
||||
<textarea class="form-control" id="modalMessage" rows="4" placeholder="Optionaler Text..."></textarea>
|
||||
</div>
|
||||
</form>
|
||||
@@ -241,6 +245,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Reset Days Modal -->
|
||||
<div class="modal fade" id="customResetModal" tabindex="-1" aria-labelledby="customResetModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="customResetModalLabel">Mahnung zurücksetzen</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Schließen"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="resetModalLoanId">
|
||||
<p>Möchten Sie die Mahnung für <strong id="resetModalStudentName"></strong> wirklich zurücksetzen?</p>
|
||||
<p class="text-muted small">Dadurch wird die Mahnstufe auf 0 gesetzt und der Schülerausweis entsperrt.</p>
|
||||
<div class="mb-3">
|
||||
<label for="resetModalDays" class="form-label">Frist verlängern um (Tage):</label>
|
||||
<input type="number" class="form-control" id="resetModalDays" value="14" min="1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button type="button" class="btn btn-warning" onclick="submitCustomReset()">Mahnung zurücksetzen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openEmailModal(loanId, studentName, studentEmail) {
|
||||
document.getElementById('modalLoanId').value = loanId;
|
||||
@@ -292,7 +321,7 @@ function resetMahnung(loanId, studentName) {
|
||||
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
|
||||
fetch('/mahnungen_reset', {
|
||||
fetch('/mahnungen_reset/14', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify({ loan_id: loanId })
|
||||
@@ -301,7 +330,46 @@ function resetMahnung(loanId, studentName) {
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert(data.message);
|
||||
location.reload(); // Seite neu laden, um die aktualisierte Ansicht zu sehen
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Fehler: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Fehler beim Senden der Anfrage.');
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
function resetMahnungCustom(loanId, studentName) {
|
||||
document.getElementById('resetModalLoanId').value = loanId;
|
||||
document.getElementById('resetModalStudentName').textContent = studentName;
|
||||
document.getElementById('resetModalDays').value = 14;
|
||||
|
||||
var customModal = new bootstrap.Modal(document.getElementById('customResetModal'));
|
||||
customModal.show();
|
||||
}
|
||||
|
||||
function submitCustomReset() {
|
||||
const loanId = document.getElementById('resetModalLoanId').value;
|
||||
const daysToExtend = parseInt(document.getElementById('resetModalDays').value, 10);
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
|
||||
if (isNaN(daysToExtend) || daysToExtend < 1) {
|
||||
alert('Bitte geben Sie eine gültige Anzahl an Tagen ein.');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/mahnungen_reset/${daysToExtend}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify({ loan_id: loanId })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert(data.message);
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Fehler: ' + data.message);
|
||||
}
|
||||
|
||||
+263
-52
@@ -266,6 +266,11 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.code-search-label {
|
||||
color: #b93845;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.search-button {
|
||||
padding: 8px 12px;
|
||||
background-color: #007bff;
|
||||
@@ -284,10 +289,10 @@
|
||||
}
|
||||
|
||||
#code-search.searching {
|
||||
background-color: #fff3cd;
|
||||
background-color: #fff5f5;
|
||||
transition: background-color 0.3s;
|
||||
border: 2px solid #ffc107;
|
||||
box-shadow: 0 0 8px rgba(255, 193, 7, 0.5);
|
||||
border: 2px solid #d65a62;
|
||||
box-shadow: 0 0 8px rgba(190, 55, 66, 0.28);
|
||||
}
|
||||
|
||||
/* QR Scanner styles */
|
||||
@@ -562,26 +567,55 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navigation-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
.items-navigation {
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0, 1fr) 42px;
|
||||
align-items: center;
|
||||
column-gap: 16px;
|
||||
width: calc(100% + 116px);
|
||||
margin-left: -58px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.navigation-buttons .prev-button,
|
||||
.navigation-buttons .next-button {
|
||||
background-color: #007bff;
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
.navigation-button-slot {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.navigation-buttons .prev-button:hover,
|
||||
.navigation-buttons .next-button:hover {
|
||||
background-color: #0056b3;
|
||||
.navigation-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
padding: 0;
|
||||
background: var(--ui-surface-soft);
|
||||
color: var(--ui-title);
|
||||
border: 1px solid var(--ui-border);
|
||||
box-shadow: var(--ui-shadow-sm);
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
transition: transform 160ms ease, box-shadow 160ms ease, background-color 160ms ease, border-color 160ms ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.navigation-button:hover {
|
||||
background: var(--ui-surface);
|
||||
border-color: var(--module-accent-color);
|
||||
box-shadow: var(--ui-shadow-md);
|
||||
transform: translateY(-2px) scale(1.04);
|
||||
}
|
||||
|
||||
.navigation-button:active {
|
||||
transform: translateY(0) scale(0.96);
|
||||
}
|
||||
|
||||
.navigation-button:focus-visible {
|
||||
outline: 3px solid rgba(37, 99, 235, 0.35);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
@@ -744,15 +778,15 @@
|
||||
}
|
||||
|
||||
.code-match {
|
||||
border: 2px solid #17a2b8 !important;
|
||||
box-shadow: 0 0 15px rgba(23, 162, 184, 0.5) !important;
|
||||
border: 2px solid #d65a62 !important;
|
||||
box-shadow: 0 0 15px rgba(190, 55, 66, 0.28) !important;
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.exact-code-match {
|
||||
border: 3px solid #28a745 !important;
|
||||
box-shadow: 0 0 20px rgba(40, 167, 69, 0.7) !important;
|
||||
border: 3px solid #b93845 !important;
|
||||
box-shadow: 0 0 20px rgba(185, 56, 69, 0.42) !important;
|
||||
position: relative;
|
||||
z-index: 11;
|
||||
}
|
||||
@@ -1633,7 +1667,13 @@
|
||||
}
|
||||
|
||||
/* Navigation buttons mobile */
|
||||
.navigation-buttons {
|
||||
.items-navigation {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
.navigation-button-slot {
|
||||
display: none !important; /* Hide horizontal navigation on mobile */
|
||||
}
|
||||
|
||||
@@ -1946,7 +1986,7 @@
|
||||
/* Standardized button styles across the application */
|
||||
.search-button, .scan-button, .filter-toggle, .clear-filter,
|
||||
.add-new-btn, .popup-close-button, .prev-image-button, .next-image-button,
|
||||
.navigation-buttons .prev-button, .navigation-buttons .next-button {
|
||||
.navigation-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -1971,6 +2011,122 @@
|
||||
box-sizing: border-box;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* iPad: use the wider canvas for cards, filters, forms, and image previews. */
|
||||
@media screen and (min-width: 768px) and (max-width: 1180px) {
|
||||
.admin-content-container {
|
||||
width: min(100%, 1120px) !important;
|
||||
max-width: 1120px !important;
|
||||
margin: 0 auto !important;
|
||||
padding: 20px 24px 28px !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||
align-items: start;
|
||||
gap: 14px !important;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
min-width: 0 !important;
|
||||
width: auto !important;
|
||||
padding: 14px !important;
|
||||
}
|
||||
|
||||
.filter-group.search-field {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.search-container input,
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
min-height: 44px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.items-container {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||
gap: 18px !important;
|
||||
padding: 14px 0 24px !important;
|
||||
overflow: visible !important;
|
||||
scroll-snap-type: none !important;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
width: auto !important;
|
||||
max-width: none !important;
|
||||
min-width: 0 !important;
|
||||
padding: 16px !important;
|
||||
scroll-snap-align: none !important;
|
||||
}
|
||||
|
||||
.item-card .image-container {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
height: auto !important;
|
||||
aspect-ratio: 4 / 3;
|
||||
margin: 10px 0 14px !important;
|
||||
overflow: hidden !important;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 12px !important;
|
||||
background: var(--ui-surface-soft);
|
||||
}
|
||||
|
||||
.item-card .image-container .item-image,
|
||||
.item-card .image-container .video-container {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.item-card .image-container .item-image {
|
||||
object-fit: contain !important;
|
||||
object-position: center;
|
||||
background: var(--ui-surface-soft);
|
||||
}
|
||||
|
||||
.item-card .image-container .item-image[data-index="0"] {
|
||||
display: block !important;
|
||||
position: relative !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.item-card .image-container .item-image:not([data-index="0"]) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.item-card .image-container .video-container {
|
||||
position: relative;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-actions > * {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
#item-modal .modal-content {
|
||||
width: min(92vw, 880px);
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
<style>
|
||||
.bookmark-btn { position:absolute; top:8px; right:8px; width:46px; height:46px; background:rgba(255,255,255,.92); border:2px solid #ffc107; border-radius:50%; font-size:1.65rem; line-height:1; display:flex; align-items:center; justify-content:center; cursor:pointer; color:#ffc107; box-shadow:0 2px 6px rgba(0,0,0,.18); transition:background .2s, transform .15s, color .2s; z-index:6; pointer-events:auto; }
|
||||
@@ -2385,7 +2541,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
|
||||
<div class="filter-group search-field">
|
||||
<div class="filter-header">
|
||||
<label for="code-search">Code suchen:</label>
|
||||
<label class="code-search-label" for="code-search">Code suchen:</label>
|
||||
<button type="button" class="clear-filter" onclick="clearCodeSearch()">Clear</button>
|
||||
</div>
|
||||
<div class="search-container">
|
||||
@@ -2427,12 +2583,16 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
<span>Anzahl</span>
|
||||
</div>
|
||||
|
||||
<div id="items-container" class="items-container">
|
||||
<!-- Items will be dynamically loaded here -->
|
||||
</div>
|
||||
<div class="navigation-buttons">
|
||||
<button class="prev-button" onclick="scrollPrev()">❮</button>
|
||||
<button class="next-button" onclick="scrollNext()">❯</button>
|
||||
<div class="items-navigation">
|
||||
<div class="navigation-button-slot">
|
||||
<button class="navigation-button prev-button" type="button" onclick="scrollPrev()" aria-label="Vorherige Elemente">❮</button>
|
||||
</div>
|
||||
<div id="items-container" class="items-container">
|
||||
<!-- Items will be dynamically loaded here -->
|
||||
</div>
|
||||
<div class="navigation-button-slot">
|
||||
<button class="navigation-button next-button" type="button" onclick="scrollNext()" aria-label="Nächste Elemente">❯</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="item-modal" class="item-modal">
|
||||
@@ -3206,9 +3366,13 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
});
|
||||
}
|
||||
|
||||
function loadItems(offset = 0, append = false) {
|
||||
function loadItems(offset = 0, append = false, searchTerm = '', searchScope = 'all') {
|
||||
// Keep list payload lightweight; full details are fetched on-demand in openItemQuick.
|
||||
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ADMIN_ITEMS_PAGE_SIZE}&light_mode=true`)
|
||||
const searchParams = searchTerm && searchScope === 'title_description'
|
||||
? `&search=${encodeURIComponent(searchTerm)}&search_scope=${encodeURIComponent(searchScope)}`
|
||||
: '';
|
||||
const limit = (searchTerm && searchScope === 'all') ? 5000 : MAIN_ADMIN_ITEMS_PAGE_SIZE;
|
||||
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${limit}&light_mode=true${searchParams}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
@@ -3227,8 +3391,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
if (!append && (!pageItems || pageItems.length === 0)) {
|
||||
itemsContainer.innerHTML = `
|
||||
<div class="no-items-message">
|
||||
<div>Keine Objekte gefunden</div>
|
||||
<a class="empty-state-cta" href="{{ url_for('upload_admin') }}">Erstellen sie hier ihr erstes Item</a>
|
||||
<div>Kein Item gefunden</div>
|
||||
</div>
|
||||
`;
|
||||
if (itemsIndicator) {
|
||||
@@ -3241,7 +3404,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
}
|
||||
pageItems.sort((a, b) => {
|
||||
const nameA = a.Name ? a.Name.toLowerCase() : '';
|
||||
const nameB = a.Name ? a.Name.toLowerCase() : '';
|
||||
const nameB = b.Name ? b.Name.toLowerCase() : '';
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
|
||||
@@ -3285,6 +3448,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
card.dataset.code = (item.Code_4 !== undefined && item.Code_4 !== null) ?
|
||||
String(item.Code_4).trim() : '';
|
||||
card.dataset.groupCodes = JSON.stringify(Array.isArray(item.GroupedAllCodes) ? item.GroupedAllCodes : []);
|
||||
card.dataset.groupedUnits = JSON.stringify(Array.isArray(item.GroupedUnitStates) ? item.GroupedUnitStates : []);
|
||||
|
||||
// Add filter values to their respective sets
|
||||
filter1Array.forEach(value => {
|
||||
@@ -3415,7 +3579,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
</div>
|
||||
{% if current_permissions.actions.get('can_borrow', False) %}
|
||||
${isAvailableForBorrow && !item.BlockedNow ?
|
||||
`<form method="POST" action="{{ url_for('ausleihen', id='') }}${item._id}">
|
||||
`<form method="POST" action="{{ url_for('ausleihen', id='') }}${item._id}" data-grouped-borrow-form>
|
||||
${isGroupedItem ? `
|
||||
<div class="grouped-borrow-controls" style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:8px; align-items:center;">
|
||||
<label style="font-size:0.85rem; margin:0;">Anzahl:</label>
|
||||
@@ -3445,6 +3609,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
:
|
||||
`<button class="ausleihen disabled-button" disabled>${item.BlockedNow ? 'Reserviert' : 'Ausgeliehen'}</button>`
|
||||
}
|
||||
${isGroupedItem ? '<div class="grouped-searched-action" data-grouped-searched-action style="display:none;"></div>' : ''}
|
||||
{% endif %}
|
||||
{% if current_permissions.actions.get('can_delete', False) %}
|
||||
<form method="POST" action="{{ url_for('delete_item', id='') }}${item._id}" style="display:inline;" onsubmit="return confirm('Sind Sie sicher, dass Sie dieses Objekt löschen möchten?')">
|
||||
@@ -3679,13 +3844,66 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
card.classList.remove('highlight-item', 'code-match', 'exact-code-match');
|
||||
});
|
||||
|
||||
applyFilters(true);
|
||||
if (codeSearchTerm) {
|
||||
loadItems(0, false, codeSearchTerm, 'all');
|
||||
} else {
|
||||
loadItems();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
searchInput.classList.remove('searching');
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function syncSearchedGroupedAction(card, searchTerm, exactCodeMatch) {
|
||||
const searchedAction = card.querySelector('[data-grouped-searched-action]');
|
||||
if (!searchedAction) return;
|
||||
|
||||
const borrowForm = card.querySelector('[data-grouped-borrow-form]');
|
||||
searchedAction.style.display = 'none';
|
||||
searchedAction.innerHTML = '';
|
||||
|
||||
if (!exactCodeMatch || !searchTerm) {
|
||||
if (borrowForm) borrowForm.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let groupedUnits = [];
|
||||
try {
|
||||
groupedUnits = JSON.parse(card.dataset.groupedUnits || '[]');
|
||||
} catch {
|
||||
groupedUnits = [];
|
||||
}
|
||||
|
||||
const matchedUnit = groupedUnits.find(unit =>
|
||||
String(unit.code || '').trim().toLowerCase() === searchTerm
|
||||
);
|
||||
if (!matchedUnit) return;
|
||||
|
||||
if (matchedUnit.available) {
|
||||
if (borrowForm) borrowForm.style.display = '';
|
||||
const specificSelect = borrowForm?.querySelector('[name="specific_item_id"]');
|
||||
if (specificSelect) {
|
||||
specificSelect.value = matchedUnit.id;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (borrowForm) borrowForm.style.display = 'none';
|
||||
searchedAction.style.display = 'block';
|
||||
if (matchedUnit.borrowed_by_current_user) {
|
||||
searchedAction.innerHTML = `
|
||||
<form method="POST" action="/zurueckgeben/${encodeURIComponent(matchedUnit.id)}">
|
||||
<button class="ausleihen" type="submit">Zurückgeben (${escapeHtml(matchedUnit.code || 'Code')})</button>
|
||||
</form>`;
|
||||
} else {
|
||||
searchedAction.innerHTML = `
|
||||
<button class="ausleihen disabled-button" type="button" disabled>
|
||||
${escapeHtml(matchedUnit.code || 'Code')} ausgeliehen
|
||||
</button>`;
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters(isDirectSearch = false) {
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
const items = itemsContainer.querySelectorAll('.item-card');
|
||||
@@ -3732,6 +3950,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
if (passesAllFilters && matchesCodeSearch && matchesDesc && (!favoritesOnly || isFavoriteCard)) {
|
||||
item.style.display = 'block';
|
||||
visibleCount++;
|
||||
syncSearchedGroupedAction(item, searchTerm, exactCodeMatch);
|
||||
|
||||
if (exactCodeMatch) {
|
||||
item.classList.add('highlight-item', 'exact-code-match');
|
||||
@@ -3756,6 +3975,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
}
|
||||
} else {
|
||||
item.style.display = 'none';
|
||||
syncSearchedGroupedAction(item, '', false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3830,7 +4050,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
card.classList.remove('highlight-item', 'code-match', 'exact-code-match');
|
||||
});
|
||||
|
||||
applyFilters();
|
||||
loadItems();
|
||||
}
|
||||
|
||||
// Description search using backend substring match
|
||||
@@ -3839,21 +4059,12 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
const term = (input?.value || '').trim();
|
||||
if (!term){
|
||||
descSearchIds = null;
|
||||
applyFilters(true);
|
||||
loadItems(0, false, codeSearchTerm, 'all');
|
||||
return;
|
||||
}
|
||||
input?.classList.add('searching');
|
||||
fetch(`/search_word/${encodeURIComponent(term)}`)
|
||||
.then(r=>r.json())
|
||||
.then(data=>{
|
||||
if (data && data.success && Array.isArray(data.response)){
|
||||
descSearchIds = new Set(data.response.map(String));
|
||||
} else {
|
||||
descSearchIds = new Set();
|
||||
}
|
||||
applyFilters(true);
|
||||
})
|
||||
.catch(()=>{ descSearchIds = new Set(); applyFilters(true); })
|
||||
descSearchIds = null;
|
||||
loadItems(0, false, term, 'title_description')
|
||||
.finally(()=>{ setTimeout(()=> input?.classList.remove('searching'), 300); });
|
||||
}
|
||||
|
||||
@@ -3861,7 +4072,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
const input = document.getElementById('desc-search');
|
||||
if (input) input.value = '';
|
||||
descSearchIds = null;
|
||||
applyFilters();
|
||||
loadItems(0, false, codeSearchTerm, 'all');
|
||||
}
|
||||
|
||||
// Open item modal with fresh details from backend.
|
||||
|
||||
@@ -21,9 +21,8 @@
|
||||
<div class="card shadow-sm border-0 h-100 rounded-3">
|
||||
<div class="card-header bg-white border-bottom py-3 d-flex justify-content-between align-items-center">
|
||||
<h2 class="card-title h5 mb-0 fw-semibold text-primary">
|
||||
<i class="bi bi-funnel me-2"></i>{{ filter_names.get('1', 'Jahrgangsstufe') }}
|
||||
<i class="bi bi-funnel me-2"></i>{{ filter_names.get('2', 'Jahrgangsstufe') }}
|
||||
</h2>
|
||||
<span class="badge bg-primary-subtle text-primary rounded-pill">Filter 1</span>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<form method="POST" action="{{ url_for('add_filter_value', filter_num=1) }}" class="mb-4">
|
||||
@@ -87,9 +86,8 @@
|
||||
<div class="card shadow-sm border-0 h-100 rounded-3">
|
||||
<div class="card-header bg-white border-bottom py-3 d-flex justify-content-between align-items-center">
|
||||
<h2 class="card-title h5 mb-0 fw-semibold text-primary">
|
||||
<i class="bi bi-funnel me-2"></i>{{ filter_names.get('2', 'Fachgebiet') }}
|
||||
<i class="bi bi-funnel me-2"></i>{{ filter_names.get('1', 'Fachgebiet') }}
|
||||
</h2>
|
||||
<span class="badge bg-primary-subtle text-primary rounded-pill">Filter 2</span>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<form method="POST" action="{{ url_for('add_filter_value', filter_num=2) }}" class="mb-4">
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user