Compare commits

...

23 Commits

Author SHA1 Message Date
Aiirondev_dev f00e7d8f74 changes with the Color of the Code suchen Box to red 2026-10-18 22:02:59 +02:00
Aiirondev_dev 7af729ac88 Changes tot the style and formating of the Website especialy when Highlighting the important Search fields 2026-10-18 21:46:18 +02:00
Aiirondev_dev 847349b419 changes to the style and formating of the Buttons 2026-10-18 21:35:13 +02:00
Aiirondev_dev 91a80c6330 Changes to the Button style for non mobile devices to have a smohter 2026-10-18 21:30:16 +02:00
Aiirondev_dev 530d71ac8f changes to the design 2026-10-18 21:10:56 +02:00
Aiirondev_dev 64991326e7 Die Ursache wurde behoben: Die Ausleihungen speichern den Benutzer teilweise als Benutzer-ObjectId, während das Fenster „Meine Ausleihungen“ nur den Session-Benutzernamen verglichen hat. Dadurch wurden die Datensätze herausgefiltert.
Zusätzlich wurde die Item-Suche robuster gemacht:

Vergleich gegen Benutzername
Vergleich gegen Benutzer-ObjectId
Vergleich gegen session['user_id']
Unterstützung für verschlüsselte und unverschlüsselte Benutzerwerte
Unterstützung für Item-IDs als String und als MongoDB-ObjectId
Geändert wurde:

Web/app.py
2026-10-15 22:01:23 +02:00
Aiirondev_dev dffcfdcba7 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
2026-10-15 21:33:41 +02:00
Aiirondev_dev b3e79a65c1 Umgesetzt:
Ausleihungen verwenden jetzt user_id statt Benutzername als Funktionsargument.
Das Feld User speichert nun die Benutzer-ID statt des Benutzernamens.
Login speichert zusätzlich session['user_id'].
Normale Ausleihen, Schülerausweis-Ausleihen und Reservierungen verwenden die Benutzer-ID.
Berechtigungsprüfungen für eigene Ausleihen/Buchungen vergleichen IDs.
Kalenderbuchungen erkennen den aktuellen Benutzer jetzt ebenfalls über die ID.
update_ausleihung() und get_ausleihung_by_user() wurden angepasst.
2026-10-15 20:58:40 +02:00
Aiirondev_dev b33b4eadc6 Implemented an iPad-focused responsive styling improvement.
Changes made
Added a dedicated iPad breakpoint for 768–1180px widths.
Improved inventory card layout:
Two-column card grid on iPads.
Cleaner spacing and card sizing.
Image previews now preserve aspect ratio with object-fit: contain.
Added consistent image backgrounds and rounded borders.
Fixed first-image visibility inside video/image containers.
Improved forms:
Two-column form layout on iPad.
Larger 44px touch-friendly controls.
Better spacing for filter groups and search fields.
Improved tables:
Rounded scroll container.
Better horizontal touch scrolling.
Preserved safe-area and touch interaction behavior.
2026-10-15 20:52:14 +02:00
Aiirondev_dev 07a1cdb063 changes to the filter preset 2026-10-07 16:30:13 +02:00
Aiirondev_dev 2942bcc380 Der Jinja-Fehler in base.html ist behoben. 2026-10-07 16:12:31 +02:00
Aiirondev_dev 349337e75f changes to dont reflekt the testing database name 2026-10-07 16:03:32 +02:00
Aiirondev_dev 9bdd8261fe changes to dont include the testing information 2026-10-07 16:02:39 +02:00
Aiirondev_dev 04c8505531 Die Suche wurde angepasst:Titel und Beschreibung werden jetzt direkt gemeinsam durchsucht.
Teiltreffer funktionieren, z. B.:
Rechen findet Rechenschiffchen
ein Fragment aus der Beschreibung findet den Artikel ebenfalls
Groß-/Kleinschreibung spielt keine Rolle
Die zusätzliche lokale ID-Filterung wurde entfernt, da sie Treffer teilweise wieder ausgeblendet hat.
Die Suche verwendet nun die serverseitige MongoDB-Abfrage mit regulären Teiltreffern.
2026-10-07 15:55:07 +02:00
Aiirondev_dev c9edf4a4c8 Die Inventarsuche wurde korrigiert. 2026-10-07 15:45:47 +02:00
Aiirondev_dev 0e0a99569e Implementation of the right Code ussage 2026-10-02 15:13:46 +02:00
Aiirondev_dev 6e94ca20c4 changes to the Mahnungssystem level fix 2026-09-27 14:40:21 +02:00
Aiirondev_dev 72fdfb0898 changes to the style of the mahnungs zurücksetzungs Modal 2026-09-27 14:35:01 +02:00
Aiirondev_dev 9de09a2f41 custom Mahnungs reset implementation 2026-09-27 14:20:44 +02:00
Aiirondev_dev 3d5a81824f changes to the Student Card clearing 2026-09-25 22:02:26 +02:00
Aiirondev_dev b0344627a7 removal of unused Code 2026-09-25 17:01:56 +02:00
Aiirondev_dev 8d07577220 changes to the asv export 2026-09-24 22:21:55 +02:00
Aiirondev_dev f3c7b6cd55 implementation of password visibility changing fuinction for the own password change 2026-09-24 17:26:25 +02:00
13 changed files with 753 additions and 295 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
+291 -208
View File
@@ -468,6 +468,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
@@ -1378,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.
@@ -1400,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 = {}
@@ -1477,32 +1583,48 @@ def update_appointment_statuses():
except Exception as e:
app.logger.warning(f"Could not update item status to True for {appointment['_id']}: {e}")
if old_status == 'planned' and new_status == 'active' and activation_user:
try:
_create_notification(
db,
audience='user',
notif_type='appointment_activated',
title='Reservierung ist jetzt aktiv',
message=f"Deine geplante Ausleihe für {activation_item_name} startet jetzt.",
target_user=activation_user,
reference={
'appointment_id': str(appointment.get('_id')),
'item_id': str(appointment.get('Item') or ''),
'event': 'activated',
},
unique_key=f"appointment:activated:{appointment.get('_id')}",
severity='info',
)
except Exception as notif_err:
app.logger.warning(
f"Failed to create activation notification for {appointment.get('_id')}: {notif_err}"
)
# Endzeitpunkt erreicht: nur einen deutlich sichtbaren
# Rückgabehinweis setzen, niemals automatisch abschließen.
if (
old_status == 'active'
and isinstance(appointment.get('End'), datetime.datetime)
and au.ensure_timezone_aware(appointment['End']) <= current_time
):
ausleihungen.update_one(
{'_id': appointment['_id'], 'Status': 'active'},
{'$set': {
'ReturnPending': True,
'ReturnReminderAt': current_time,
'LastUpdated': current_time,
}}
)
if old_status == 'planned' and new_status == 'active' and activation_user:
try:
_create_notification(
db,
audience='user',
notif_type='appointment_activated',
title='Reservierung ist jetzt aktiv',
message=f"Deine geplante Ausleihe für {activation_item_name} startet jetzt.",
target_user=activation_user,
reference={
'appointment_id': str(appointment.get('_id')),
'item_id': str(appointment.get('Item') or ''),
'event': 'activated',
},
unique_key=f"appointment:activated:{appointment.get('_id')}",
severity='info',
)
except Exception as notif_err:
app.logger.warning(
f"Failed to create activation notification for {appointment.get('_id')}: {notif_err}"
)
# -----------------------------------------------------------------
# Mahnlauf für Bibliotheksartikel (Prüfung auf Überfälligkeit)
# -----------------------------------------------------------------
elif item_is_library:
if item_is_library:
appt = appointment # Verwende das aktuelle Dokument aus der Schleife
if appt.get('Status') != 'active':
continue
@@ -1560,7 +1682,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 +3628,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 +3747,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 +3776,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 +3810,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:
@@ -4366,9 +4350,12 @@ def api_library_scan_action():
due_date = now + datetime.timedelta(days=borrow_duration_days)
it.update_item_status(item_id, False, borrower_name)
borrower_user = us.get_user_by_student_card(student_card_id)
if not borrower_user or not borrower_user.get('_id'):
return jsonify({'ok': False, 'message': 'Kein Benutzer für diesen Schülerausweis gefunden.'}), 404
au.add_ausleihung(
item_id,
borrower_name,
str(borrower_user['_id']),
now,
end_date=due_date,
due_date=due_date,
@@ -5605,6 +5592,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 +5723,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 +5760,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 +5862,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 +5871,7 @@ def get_items():
'ParentItemId': 1,
'Code_4': 1,
'Verfuegbar': 1,
'User': 1,
'Name': 1,
'Images': 1,
'ThumbnailInfo': 1,
@@ -5871,13 +5899,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 +5931,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 +5993,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 +6039,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 +6159,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 +7503,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 +7581,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 +7656,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 +7753,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 +7804,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 +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)
@@ -7816,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'
})
@@ -7832,6 +7886,7 @@ def zurueckgeben(id):
{'$set': {
'Status': 'completed',
'End': end_date,
'ReturnPending': False,
'LastUpdated': datetime.datetime.now(ZoneInfo("Europe/Berlin"))
}}
)
@@ -7917,7 +7972,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)
@@ -8199,7 +8259,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 +8312,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 +8379,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 +8410,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
@@ -10913,7 +10975,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 +11510,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 +11533,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 +11565,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 +11590,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)'}
@@ -12281,7 +12357,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,
@@ -13775,6 +13851,13 @@ def upload_csv_batch():
"images_failed": error_count
}), 200
@app.route('/get/asv_export', methods=['GET'])
def get_asv_export():
if 'username' not in session:
flash('Bitte melden Sie sich an.', 'error')
return redirect(url_for('login'))
return send_from_directory(app.static_folder, 'img/asv_export_invario_2026.exfj')
"""
@app.route('/test_email')
def test_email():
+12 -17
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:
@@ -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):
+36
View File
@@ -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 */
/* ========================================================= */
Binary file not shown.
-3
View File
@@ -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">
+38 -5
View File
@@ -26,18 +26,27 @@
<form method="POST" action="{{ url_for('change_password') }}">
<div class="form-group">
<label for="current_password">Aktuelles Passwort:</label>
<input type="password" id="current_password" name="current_password" required>
<div class="input-group">
<input type="password" class="form-control" id="current_password" name="current_password" required>
<button class="btn btn-outline-secondary" type="button" onclick="togglePasswordVisibility('current_password', this)">Anzeigen</button>
</div>
</div>
<div class="form-group">
<label for="new_password">Neues Passwort:</label>
<input type="password" id="new_password" name="new_password" required>
<div class="input-group">
<input type="password" class="form-control" id="new_password" name="new_password" required>
<button class="btn btn-outline-secondary" type="button" onclick="togglePasswordVisibility('new_password', this)">Anzeigen</button>
</div>
<small class="form-text text-muted">Das Passwort sollte mindestens 6 Zeichen lang sein.</small>
</div>
<div class="form-group">
<label for="confirm_password">Neues Passwort bestätigen:</label>
<input type="password" id="confirm_password" name="confirm_password" required>
<div class="input-group">
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required>
<button class="btn btn-outline-secondary" type="button" onclick="togglePasswordVisibility('confirm_password', this)">Anzeigen</button>
</div>
</div>
<div class="form-actions">
@@ -48,6 +57,20 @@
</div>
</div>
<script>
function togglePasswordVisibility(fieldId, button) {
var passwordInput = document.getElementById(fieldId);
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
button.textContent = 'Verbergen';
} else {
passwordInput.type = 'password';
button.textContent = 'Anzeigen';
}
}
</script>
<style>
.password-change-form {
max-width: 500px;
@@ -74,7 +97,7 @@
font-weight: bold;
}
input[type="password"] {
.form-control {
width: 100%;
padding: 10px;
border: 1px solid #ced4da;
@@ -122,6 +145,16 @@
.btn-secondary:hover {
background-color: #5a6268;
}
.btn-outline-secondary {
border: 1px solid #ced4da;
background-color: #f8f9fa;
color: #495057;
}
.btn-outline-secondary:hover {
background-color: #e2e6ea;
}
.alert {
padding: 12px;
@@ -141,4 +174,4 @@
border: 1px solid #c3e6cb;
}
</style>
{% endblock %}
{% endblock %}
+1
View File
@@ -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';
+73 -5
View File
@@ -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);
}
+261 -50
View File
@@ -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()">&#10094;</button>
<button class="next-button" onclick="scrollNext()">&#10095;</button>
<div class="items-navigation">
<div class="navigation-button-slot">
<button class="navigation-button prev-button" type="button" onclick="scrollPrev()" aria-label="Vorherige Elemente">&#10094;</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">&#10095;</button>
</div>
</div>
</div>
<div id="item-modal" class="item-modal">
@@ -3206,9 +3366,12 @@ 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
? `&search=${encodeURIComponent(searchTerm)}&search_scope=${encodeURIComponent(searchScope)}`
: '';
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ADMIN_ITEMS_PAGE_SIZE}&light_mode=true${searchParams}`)
.then(response => response.json())
.then(data => {
const itemsContainer = document.querySelector('#items-container');
@@ -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.
+2 -4
View File
@@ -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">
+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>
@@ -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;
+1
View File
@@ -514,6 +514,7 @@
<p style="margin:0 0 12px 0; color:#555; font-size:13px; line-height:1.4;">
Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch (z. B. aus <strong>ASV</strong>). Erkannt werden Rufame, Nachname, Klasse*, Ausweis-ID*, lokales Differenzierungsmerkmal*, Notizen* & Ausleihdauer*.
Hinweis: Die Daten werden Validiert und müssen dann noch einmal aus sicherheits gründen hochgeladen werden!
Im folgenden ist eine export vorlage für ihre ASV-Software hinterlegt: <a href="{{ url_for('get_asv_export') }}" target="_blank" style="color:#007bff; text-decoration:underline;">ASV Export Vorlage</a>
</p>
</div>